Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
b9ed8b2
Plan: client attach on join (LLP 0046, implements 0045)
philcunliffe Jun 26, 2026
330bc41
Attach policy reader: tri-state readAttachPolicy (LLP 0044/0045)
philcunliffe Jun 26, 2026
7e8b728
Client attach: reconcile-context seam (clientDescriptors/clients/endp…
philcunliffe Jun 26, 2026
949768b
Per-plugin attach config validation: validateAttachSection (LLP 0045 T8)
philcunliffe Jun 26, 2026
3cf01a1
T1: attach() writes a self-describing undo record (LLP 0045/0046)
philcunliffe Jun 26, 2026
d7d52ac
Merge remote-tracking branch 'origin/task/client-attach/T1' into HEAD
philcunliffe Jun 26, 2026
0ea15ba
Merge remote-tracking branch 'origin/task/client-attach/T2' into HEAD
philcunliffe Jun 26, 2026
7a43125
Merge remote-tracking branch 'origin/task/client-attach/T3' into HEAD
philcunliffe Jun 26, 2026
2be278b
Merge remote-tracking branch 'origin/task/client-attach/T8' into HEAD
philcunliffe Jun 26, 2026
4c28262
The single core undo (= detach): disk-driven client_detach_disk.js (L…
philcunliffe Jun 26, 2026
365f435
T9: status surface — declared-attach-targets derivation (LLP 0044/0045)
philcunliffe Jun 26, 2026
b1bc47d
Merge remote-tracking branch 'origin/task/client-attach/T4' into HEAD
philcunliffe Jun 26, 2026
9564c91
Merge remote-tracking branch 'origin/task/client-attach/T9' into HEAD
philcunliffe Jun 26, 2026
0b4b90f
Attach handler: action_attach.js (createAttachHandler + attachHandler…
philcunliffe Jun 26, 2026
02e1770
Client attach: retire adapter detach(), reroute manual detach to the …
philcunliffe Jun 26, 2026
2a20f6a
Merge remote-tracking branch 'origin/task/client-attach/T5' into HEAD
philcunliffe Jun 26, 2026
0dc8d86
Merge remote-tracking branch 'origin/task/client-attach/T6' into HEAD
philcunliffe Jun 26, 2026
58b1feb
Client attach: daemon wiring — resolve client seam + register [attach…
philcunliffe Jun 26, 2026
068a957
Merge remote-tracking branch 'origin/task/client-attach/T7' into HEAD
philcunliffe Jun 26, 2026
5b972c8
LLP 0045: flip Status Accepted -> Active (client attach shipped)
philcunliffe Jun 26, 2026
f77ed3f
Merge remote-tracking branch 'origin/task/client-attach/T10' into HEAD
philcunliffe Jun 26, 2026
7432284
client-attach: legacy-marker detach fallback; descriptor-map manual d…
philcunliffe Jun 27, 2026
e32353e
client-attach: detach works without gateway; TOCTOU stat-before-read;…
philcunliffe Jun 27, 2026
cdab0e5
client-attach: unlink temp file on partial write; daemon auto-attach …
philcunliffe Jun 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 15 additions & 18 deletions collectivus-plugin-kernel-types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1273,9 +1273,10 @@ export interface VerbRegistry {
* - register upstream presets (`registerUpstreamPreset`) that own
* routing — the gateway no longer has any hardcoded provider routing
* such as Anthropic-header or `/v1/messages` matching;
* - register client attach/detach helpers (`registerClient`) so the
* shared `hyp attach`/`hyp detach` CLI can dispatch without coupling
* core to client-specific code;
* - register a client `attach()` helper (`registerClient`) so the
* shared `hyp attach` CLI can dispatch without coupling core to
* client-specific code (the reversing detach is a core disk-driven
* undo, not a per-adapter hook);
* - register exchange projectors (`registerExchangeProjector`) that
* turn a captured HTTP/SSE exchange into a normalized list of
* conversation messages. The gateway expands the projector's output
Expand Down Expand Up @@ -1304,8 +1305,8 @@ export interface AiGatewayCapability {
/**
* Look up a registered client by name. Returns `undefined` when no
* adapter plugin has registered under that name. Used by the shared
* `hyp attach`/`hyp detach` command router to dispatch to the right
* adapter without coupling core to plugin-specific code.
* `hyp attach` command router to dispatch to the right adapter
* without coupling core to plugin-specific code.
*/
getClient(name: string): AiGatewayClientRegistration | undefined
/**
Expand Down Expand Up @@ -1369,11 +1370,19 @@ export interface AiGatewayEndpointOptions {
upstream?: string
}

/**
* An adapter owns only `attach()`. The reversing detach is the single
* core, disk-driven undo (`detachClientFromDisk`) that both the manual
* `hyp detach` command and the daemon reconciler's `reverse()` route
* through, so there is no per-adapter detach for the one undo to drift
* from.
*
* @ref LLP 0045#part-3--reverse-runs-from-disk-the-marker-is-a-self-describing-undo-record [constrained-by] — AiGatewayClientRegistration.detach is retired; the sole undo lives in core
*/
export interface AiGatewayClientRegistration {
name: string
defaultUpstream: string
attach(ctx: AiGatewayClientAttachContext): Promise<void>
detach(ctx: AiGatewayClientDetachContext): Promise<void>
status?(ctx: AiGatewayClientStatusContext): Promise<JsonObject>
}

Expand All @@ -1397,18 +1406,6 @@ export interface AiGatewayClientAttachContext {
json?: boolean
}

export interface AiGatewayClientDetachContext {
config: JsonObject
stdout: WriteStream
stderr: WriteStream
dryRun?: boolean
/**
* When true the adapter must emit machine-readable JSON on stdout
* instead of human prose. One JSON object per detach call.
*/
json?: boolean
}

export interface AiGatewayClientStatusContext {
config: JsonObject
}
Expand Down
6 changes: 4 additions & 2 deletions hypaware-core/plugins-workspace/ai-gateway/src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,10 @@ export function createAiGatewayApi(state) {
if (typeof client.defaultUpstream !== 'string' || client.defaultUpstream.length === 0) {
throw new TypeError(`registerClient '${client.name}': defaultUpstream is required`)
}
if (typeof client.attach !== 'function' || typeof client.detach !== 'function') {
throw new TypeError(`registerClient '${client.name}': attach()/detach() are required`)
// An adapter owns only attach(); the reversing detach is the single
// core disk-driven undo (LLP 0045 §Part 3), not a per-adapter hook.
if (typeof client.attach !== 'function') {
throw new TypeError(`registerClient '${client.name}': attach() is required`)
}
state.clients.set(client.name, client)
},
Expand Down
58 changes: 50 additions & 8 deletions hypaware-core/plugins-workspace/claude/src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@

/**
* Config validation for the `@hypaware/claude` plugin's own `config`
* block. v1 validates only the optional `backfill` sub-object that drives
* backfill-on-join — `{ on_join, window_days }`. Every other key (e.g.
* `proxy`) passes through untouched so existing configs keep working;
* there is no top-level `backfill` section and nothing new for core to
* validate.
* block. v1 validates the optional `backfill` sub-object that drives
* backfill-on-join — `{ on_join, window_days }` — and the optional
* `attach` sub-object that drives attach-on-join — `{ on_join }`. Every
* other key (e.g. `proxy`) passes through untouched so existing configs
* keep working; there is no top-level `backfill`/`attach` section and
* nothing new for core to validate.
*
* Pure and dependency-free: it returns a `ValidationResult` so it plugs
* straight into `ctx.configRegistry.registerSection` and is callable from
Expand All @@ -20,8 +21,9 @@ export const CLAUDE_CONFIG_SECTION = 'claude'

/**
* Validate the `@hypaware/claude` plugin config slice. Only the optional
* `backfill` policy block is checked; unknown sibling keys are ignored so
* the validator stays additive over the existing config surface.
* `backfill` and `attach` policy blocks are checked; unknown sibling keys
* are ignored so the validator stays additive over the existing config
* surface.
*
* @ref LLP 0037#per-plugin-config-kernel-generic-reconciler [implements] —
* backfill policy ({ on_join, window_days }) lives in and is validated
Expand All @@ -37,7 +39,10 @@ export function validateClaudeConfig(value) {
return { ok: false, errors: [{ pointer: '', message: 'claude config must be an object' }] }
}
const raw = /** @type {Record<string, unknown>} */ (value)
const errors = validateBackfillSection(raw.backfill, '/backfill')
const errors = [
...validateBackfillSection(raw.backfill, '/backfill'),
...validateAttachSection(raw.attach, '/attach'),
]
if (errors.length > 0) return { ok: false, errors }
return { ok: true }
}
Expand Down Expand Up @@ -82,3 +87,40 @@ export function validateBackfillSection(value, pointer) {
}
return errors
}

/**
* Validate the optional `attach` policy block on a client-adapter plugin's
* config: `on_join` (whether the daemon auto-attaches this client when a
* joined host confirms a central config that enables it, boolean,
* default true). Optional; unknown keys are rejected so a typo
* (`on_joins`) surfaces instead of being silently ignored. Pure — the
* caller chooses where the returned pointers mount.
*
* @ref LLP 0045#part-4--per-plugin-attach-config--status-surface [implements] —
* attach.on_join rides the client adapter's own config block, validated
* by this plugin's config-section validator beside validateBackfillSection;
* no top-level/core schema.
*
* @param {unknown} value
* @param {string} pointer JSON-pointer prefix for the `attach` object
* @returns {ValidationError[]}
*/
export function validateAttachSection(value, pointer) {
/** @type {ValidationError[]} */
const errors = []
if (value === undefined) return errors
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
errors.push({ pointer, message: 'attach must be an object' })
return errors
}
const raw = /** @type {Record<string, unknown>} */ (value)
if (raw.on_join !== undefined && typeof raw.on_join !== 'boolean') {
errors.push({ pointer: `${pointer}/on_join`, message: 'attach.on_join must be a boolean' })
}
for (const key of Object.keys(raw)) {
if (key !== 'on_join') {
errors.push({ pointer: `${pointer}/${key}`, message: `unknown attach key '${key}'` })
}
}
return errors
}
123 changes: 7 additions & 116 deletions hypaware-core/plugins-workspace/claude/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,15 @@ import { fileURLToPath } from 'node:url'
import { Attr, getLogger, withSpan } from '../../../../src/core/observability/index.js'
import { defaultConfigPath } from '../../../../src/core/config/schema.js'
import { CLAUDE_CONFIG_SECTION, validateClaudeConfig } from './config.js'
import { attach, defaultSettingsPath, detach } from './settings.js'
import { attach, defaultSettingsPath } from './settings.js'
import { anthropicUpstreamPreset, createClaudeExchangeProjector } from './projector.js'
import { createClaudeBackfillProvider } from './backfill.js'
import { createClaudeSettlementEnricher } from './settle.js'
import { defaultSessionContextFile } from './session_context.js'
import { runClaudeSessionContextHook } from './hook_command.js'

/**
* @import { AiGatewayCapability, AiGatewayClientAttachContext, AiGatewayClientDetachContext, CommandRunContext, HypAwareV2Config, PluginActivationContext } from '../../../../collectivus-plugin-kernel-types.d.ts'
* @import { AiGatewayCapability, AiGatewayClientAttachContext, CommandRunContext, HypAwareV2Config, PluginActivationContext } from '../../../../collectivus-plugin-kernel-types.d.ts'
*/

const PLUGIN_NAME = '@hypaware/claude'
Expand Down Expand Up @@ -53,16 +53,17 @@ export function claudeSessionContextFile(ctx) {
*
* Resolves the `hypaware.ai-gateway@^2.0.0` capability, registers
* the Anthropic upstream preset (path + header signature match) and
* the full Anthropic exchange projector, wires attach/detach against
* the full Anthropic exchange projector, wires `attach()` against
* `~/.claude/settings.json`, and contributes the three Claude-targeted
* helper skills. The projector reads
* `<stateDir>/session-context.jsonl` (written by the managed Claude
* hook) for `cwd` / `git_branch` and walks the local Claude JSONL
* transcripts under `<HOME>/.claude/projects` for native DAG identity.
*
* Each attach/detach emits a `client.attach`/`client.detach` span
* tagged with `hyp_plugin`, `client_name`, `status`, and
* `restored=true|false`.
* `attach()` emits a `client.attach` span tagged with `hyp_plugin`,
* `client_name`, `status`, and `restored=true|false`. The reversing
* detach is the single core disk-driven undo (LLP 0045 §Part 3), not a
* per-adapter hook.
*
* @param {PluginActivationContext} ctx
* @ref LLP 0016#knows-nothing-about-claude-or-codex [implements] — adapter requires the ai-gateway capability; registers client + upstream preset
Expand Down Expand Up @@ -205,68 +206,6 @@ export async function activate(ctx) {
{ component: 'plugin.claude' }
)
},
/** @param {AiGatewayClientDetachContext} detachCtx */
async detach(detachCtx) {
const homeDir = ctx.env.HOME ?? os.homedir()
const settingsPath = defaultSettingsPath(homeDir)

return withSpan(
'client.detach',
{
[Attr.PLUGIN]: PLUGIN_NAME,
[Attr.OPERATION]: 'client.detach',
client_name: CLIENT_NAME,
hyp_client: CLIENT_NAME,
dry_run: detachCtx.dryRun === true,
},
async (span) => {
if (detachCtx.dryRun) {
span.setAttribute('status', 'ok')
span.setAttribute('restored', false)
writeDetachOutput(detachCtx, {
status: 'ok',
client: CLIENT_NAME,
dryRun: true,
settingsPath,
changed: false,
})
return
}
try {
const result = await detach({ settingsPath })
const restored = result.changed === true
span.setAttribute('status', 'ok')
span.setAttribute('restored', restored)
if (restored) {
logger.info('client.detach.write', {
hyp_plugin: PLUGIN_NAME,
hyp_client: CLIENT_NAME,
settings_path: settingsPath,
changed: true,
})
}
writeDetachOutput(detachCtx, {
status: 'ok',
client: CLIENT_NAME,
dryRun: false,
settingsPath,
changed: restored,
removed: result.changed && result.removed !== undefined
? result.removed
: undefined,
warning: result.changed && result.warning !== undefined
? result.warning
: undefined,
})
} catch (err) {
span.setAttribute('status', 'failed')
span.setAttribute('restored', false)
throw err
}
},
{ component: 'plugin.claude' }
)
},
})

ctx.commands.register({
Expand Down Expand Up @@ -514,51 +453,3 @@ function writeAttachOutput(attachCtx, fields) {
}
}

/**
* Render detach output: machine-readable JSON when `json` is set,
* otherwise the human prose. Keeps the JSON shape stable so callers
* can grep it.
*
* @param {AiGatewayClientDetachContext} detachCtx
* @param {{
* status: 'ok' | 'failed',
* client: string,
* dryRun: boolean,
* settingsPath: string,
* changed: boolean,
* removed?: string,
* warning?: string,
* }} fields
*/
function writeDetachOutput(detachCtx, fields) {
if (detachCtx.json) {
/** @type {Record<string, unknown>} */
const payload = {
status: fields.status,
action: 'detach',
client: fields.client,
dry_run: fields.dryRun,
settings_path: fields.settingsPath,
changed: fields.changed,
}
if (fields.removed !== undefined) payload.removed = fields.removed
if (fields.warning !== undefined) payload.warning = fields.warning
detachCtx.stdout.write(JSON.stringify(payload) + '\n')
return
}
if (fields.dryRun) {
detachCtx.stdout.write(`(dry-run) Would detach Claude Code from ${fields.settingsPath}\n`)
return
}
if (fields.changed) {
detachCtx.stdout.write(`✓ Claude Code reverted (${fields.settingsPath})\n`)
if (fields.removed !== undefined) {
detachCtx.stdout.write(` Removed ANTHROPIC_BASE_URL=${fields.removed}\n`)
}
if (fields.warning !== undefined) {
detachCtx.stdout.write(` warning: ${fields.warning}\n`)
}
} else {
detachCtx.stdout.write(`No HypAware marker found in ${fields.settingsPath}; nothing to do.\n`)
}
}
Loading