Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions apps/claude-sdk-cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- A notice now prints in the conversation whenever a tool's disabled/enabled state actually flips on a config reload (e.g. AzCli/EscalatedAzCli becoming available as an account is configured)
- A running session can now move to another working directory from command mode, without restarting the process
- A say over the wire can carry object-store attachments (images, PDFs): each block resolves from the bucket it names, the bytes are inlined for the model, the committed message keeps the reference block, and a say whose attachment does not resolve is rejected attachment_unavailable
- Add --config flag to override any config value with a JSON object
- Add --file flag to start with a file as the first message
- Add --model flag: launch-time model override
Expand Down Expand Up @@ -80,6 +81,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Support reading PDF and image files as native API content blocks
- Survive a mid-turn network drop: keep the machine awake during a request, persist the conversation as each message is sent and answered, and resume an interrupted turn from an empty submit
- Tell the model the working directory: state it up front, and report the from/to when it changes mid-session
- The session watches its conversation's attachment leaf and stands down when another instance attaches: it stops answering wire requests, stops committing changes, publishes detached, and tells the user
- Track session history per working directory for future session picker
- Write BetaMessage per turn to ~/.claude/audit/<conversation-id>.jsonl

Expand All @@ -89,9 +91,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- A broken dependency wiring now fails the build or startup
- Add a plain-ASCII fast path to the TUI cell-grid layout, skipping Intl.Segmenter and stringWidth for rows with no ANSI styling and no wide or combining characters, cutting per-frame layout cost for plain-text rows
- Adopt core-di-lite property injection end to end: the container resolves the whole graph eagerly, SQLite databases are created through a registered factory, and CLI startup moves into main() so the entry module's only import-time effect is invoking it
- Agent requests answer with the current reply vocabulary: invalid (with detail) for a missing conversationId or cwd, and replies may carry a detail field
- Attachment claims now publish on the conversation's own tree (conv.v2.{id}.attachment.attached/moved/detached) carrying the world/instanceId identity pair, cwd, tip, and pulse interval; a directory change publishes moved instead of a second attached, and nothing publishes on the old agent.v1 attachment subjects
- Az account config: reader/holder identities are now configured with a type (cert or interactive) and optional subscriptionIds, replacing readerClientId/holderClientId
- AzCli, EscalatedAzCli, and AzureDevOps.PullRequest.* are hidden from a turn's tools whenever no matching account is configured
- Block header dividers now pad to a fixed minimum width instead of the full terminal width, so the trailing run of hyphens no longer scales with the window while short headers still line up
- Change events on the wire now carry the publishing instance id as envelope provenance, and a tool_result message no longer fabricates a from sender
- claude-cli now records each session's directory to a central store and resumes the most-recent session for the current directory, so a conversation survives a restart or a machine going away
- Command mode can now be entered, navigated, and exited while a query is streaming, not only in the editor phase
- Config system tracks which file each value came from
Expand Down
5 changes: 5 additions & 0 deletions apps/claude-sdk-cli/changes.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -166,3 +166,8 @@
{"description":"ExecV3 now overrides AZURE_CONFIG_DIR and strips ambient Azure credential env vars, so a model-driven az command can never inherit a real ambient session or SDK credential","category":"security"}
{"description":"Az account config: reader/holder identities are now configured with a type (cert or interactive) and optional subscriptionIds, replacing readerClientId/holderClientId","category":"changed"}
{"description":"A notice now prints in the conversation whenever a tool's disabled/enabled state actually flips on a config reload (e.g. AzCli/EscalatedAzCli becoming available as an account is configured)","category":"added"}
{"description":"Attachment claims now publish on the conversation's own tree (conv.v2.{id}.attachment.attached/moved/detached) carrying the world/instanceId identity pair, cwd, tip, and pulse interval; a directory change publishes moved instead of a second attached, and nothing publishes on the old agent.v1 attachment subjects","category":"changed"}
{"description":"The session watches its conversation's attachment leaf and stands down when another instance attaches: it stops answering wire requests, stops committing changes, publishes detached, and tells the user","category":"added"}
{"description":"A say over the wire can carry object-store attachments (images, PDFs): each block resolves from the bucket it names, the bytes are inlined for the model, the committed message keeps the reference block, and a say whose attachment does not resolve is rejected attachment_unavailable","category":"added"}
{"description":"Change events on the wire now carry the publishing instance id as envelope provenance, and a tool_result message no longer fabricates a from sender","category":"changed"}
{"description":"Agent requests answer with the current reply vocabulary: invalid (with detail) for a missing conversationId or cwd, and replies may carry a detail field","category":"changed"}
1 change: 1 addition & 0 deletions apps/claude-sdk-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"@anthropic-ai/sdk": "^0.92.0",
"@js-joda/core": "^5.7.0",
"@nats-io/nats-core": "^3.4.0",
"@nats-io/obj": "^3.4.0",
"@nats-io/transport-node": "^3.4.0",
"@shellicar/claude-core": "workspace:^",
"@shellicar/claude-sdk": "workspace:^",
Expand Down
12 changes: 7 additions & 5 deletions apps/claude-sdk-cli/spec/agent.v1.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,11 @@ const sender = z.looseObject({
// the id); ephemeral reach-handles (pid, port, tmux coords) are not named —
// they ride as open fields under looseObject (nats-spec, Naming).

// agent.v1.{world}.telemetry.>
// agent.v1.{world}.telemetry.> — attachment claims are not here; their
// schema lives on the conversation's own tree (conversation-spec.md, Attachment).
export const agentTelemetry = {
ready: z.looseObject({ ts, instanceId: z.string(), host: z.string().optional() }),
pulse: z.looseObject({ ts, instanceId: z.string(), intervalS: z.number().int().positive() }),
attached: z.looseObject({ ts, instanceId: z.string(), conversationId: z.string(), cwd: z.string().optional() }),
detached: z.looseObject({ ts, instanceId: z.string(), conversationId: z.string() }),
} as const;

// agent.v1.{world}.requests.> — a leaf not listed is still answered:
Expand All @@ -37,5 +36,8 @@ export const agentRequest = {
} as const;

// Replies (transport truth, never outcome). Known reasons today:
// already_attached, at_capacity, not_found, unsupported.
export const agentRequestReply = z.union([z.looseObject({ accepted: z.literal(true) }), z.looseObject({ rejected: z.literal(true), reason: z.string() })]);
// already_attached, at_capacity, invalid, invalid_cwd, not_found, failed,
// unsupported. `detail` is optional free-text diagnostics for a human —
// `reason` is the machine-facing token a caller branches on, `detail` names
// the step and underlying error; never the other way around.
export const agentRequestReply = z.union([z.looseObject({ accepted: z.literal(true) }), z.looseObject({ rejected: z.literal(true), reason: z.string(), detail: z.string().optional() })]);
29 changes: 24 additions & 5 deletions apps/claude-sdk-cli/spec/conv.v1.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,20 +55,39 @@ export const conversationTelemetry = z.discriminatedUnion('type', [
]);

// conv.v1.{conversationId}.changes
// v1 differs from v2 in shape, not vocabulary (conversation-spec, The v1 tree): message shapes are
// identical to v2 — `from` absent on a tool_result delivery, `instanceId` as envelope provenance.
export const conversationChange = z.discriminatedUnion('type', [
z.looseObject({ type: z.literal('message'), ts, id: z.string(), ...turnRef, role: openEnum(['user', 'assistant']), from: sender, content: contentBlocks }),
z.looseObject({ type: z.literal('revision'), ts, messageId: z.string(), content: contentBlocks }),
z.looseObject({ type: z.literal('tip_moved'), ts, to: z.string() }),
z.looseObject({ type: z.literal('message'), ts, instanceId: z.string().optional(), id: z.string(), ...turnRef, role: openEnum(['user', 'assistant']), from: sender.optional(), content: contentBlocks }),
z.looseObject({ type: z.literal('revision'), ts, instanceId: z.string().optional(), messageId: z.string(), content: contentBlocks }),
z.looseObject({ type: z.literal('tip_moved'), ts, instanceId: z.string().optional(), to: z.string() }),
]);

// conv.v1.{conversationId}.deltas — deliberately bare: the envelope's `ts` is
// waived on purpose; deltas are ephemeral and the metadata would outweigh the data.
export const conversationDelta = z.looseObject({ type: z.literal('delta'), text: z.string() });
export const conversationDelta = z.discriminatedUnion('type', [z.looseObject({ type: z.literal('delta'), text: z.string() }), z.looseObject({ type: z.literal('block'), blockType: openEnum(['thinking', 'text', 'tool_use']) })]);

// conv.v1.{conversationId}.requests — a request whose `type` is not defined
// here is still answered: `rejected` with reason `unsupported`. Compliance is
// answering, not implementing.
export const conversationRequest = z.discriminatedUnion('type', [z.looseObject({ type: z.literal('say'), ts, from: sender, text: z.string(), precondition: z.looseObject({ tip: z.string() }).optional() }), z.looseObject({ type: z.literal('cancel'), ts, from: sender.optional(), id: z.string() })]);
export const conversationRequest = z.discriminatedUnion('type', [
z.looseObject({
type: z.literal('say'),
ts,
from: sender,
text: z.string(),
attachments: z
.array(
z.looseObject({
type: z.string(),
source: z.looseObject({ type: z.string(), id: z.string(), mediaType: z.string().optional(), size: z.number().int().optional() }),
}),
)
.optional(),
precondition: z.looseObject({ tip: z.string().nullable() }),
}),
z.looseObject({ type: z.literal('cancel'), ts, from: sender.optional(), id: z.string() }),
]);

// Replies (transport truth, never outcome). Known reasons today:
// stale, not_found, already_complete, unsupported.
Expand Down
23 changes: 18 additions & 5 deletions apps/claude-sdk-cli/spec/conv.v2.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,25 @@ export const conversationTelemetry = {
}),
} as const;

// conv.v2.{conversationId}.changes.>
// conv.v2.{conversationId}.changes.> — instanceId is envelope metadata
// (beside from, never inside it): which agent instance published the change.
export const conversationChange = {
message: z.looseObject({ ts, id: z.string(), ...turnRef, role: openEnum(['user', 'assistant']), from: sender, content: contentBlocks }),
revision: z.looseObject({ ts, messageId: z.string(), content: contentBlocks }),
'tip.moved': z.looseObject({ ts, to: z.string() }),
query: z.looseObject({ ts, queryId: z.string(), reason: openEnum(['completed', 'cancelled', 'aborted']) }),
message: z.looseObject({ ts, instanceId: z.string().optional(), id: z.string(), ...turnRef, role: openEnum(['user', 'assistant']), from: sender.optional(), content: contentBlocks }),
revision: z.looseObject({ ts, instanceId: z.string().optional(), messageId: z.string(), content: contentBlocks }),
'tip.moved': z.looseObject({ ts, instanceId: z.string().optional(), to: z.string() }),
query: z.looseObject({ ts, instanceId: z.string().optional(), queryId: z.string(), reason: openEnum(['completed', 'cancelled', 'aborted']) }),
} as const;

// conv.v2.{conversationId}.attachment.> — the wire shape of the model
// agent-spec.md conducts (singular, unconditionally superseding). world is
// provenance, never address, exactly like instanceId — but together they
// are the instance identity (agent-spec.md, The entity), so world is
// required of every compliant publisher; optional here only for producers
// that predate this rule.
export const conversationAttachment = {
attached: z.looseObject({ ts, instanceId: z.string(), world: z.string().optional(), cwd: z.string().optional(), tip: z.string().nullable().optional(), intervalS: z.number().int().positive().optional() }),
moved: z.looseObject({ ts, instanceId: z.string(), world: z.string().optional(), cwd: z.string() }),
detached: z.looseObject({ ts, instanceId: z.string(), world: z.string().optional() }),
} as const;

// conv.v2.{conversationId}.deltas — the one flat subject: `delta` and `block`
Expand Down
3 changes: 2 additions & 1 deletion apps/claude-sdk-cli/spec/generate-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { z } from 'zod';
import { agentRequest, agentRequestReply, agentTelemetry } from './agent.v1.zod.js';
import { answerReply, approvalLifecycle, approvalRequest, approvalTelemetry } from './approval.v1.zod.js';
import { conversationChange, conversationDelta, conversationRequest, conversationTelemetry, requestReply } from './conv.v1.zod.js';
import { conversationChange as conversationChangeV2, conversationDelta as conversationDeltaV2, conversationRequest as conversationRequestV2, conversationTelemetry as conversationTelemetryV2, requestReply as requestReplyV2 } from './conv.v2.zod.js';
import { conversationAttachment as conversationAttachmentV2, conversationChange as conversationChangeV2, conversationDelta as conversationDeltaV2, conversationRequest as conversationRequestV2, conversationTelemetry as conversationTelemetryV2, requestReply as requestReplyV2 } from './conv.v2.zod.js';

const schemas = {
'conv.telemetry': conversationTelemetry,
Expand All @@ -35,6 +35,7 @@ const schemas = {
const leafedSchemas = {
'conv.v2.telemetry': conversationTelemetryV2,
'conv.v2.changes': conversationChangeV2,
'conv.v2.attachment': conversationAttachmentV2,
'conv.v2.requests': conversationRequestV2,
'agent.telemetry': agentTelemetry,
'agent.requests': agentRequest,
Expand Down
58 changes: 43 additions & 15 deletions apps/claude-sdk-cli/src/agent/AgentPresence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,35 +7,45 @@ import { IBus } from '../bus/IBus.js';
import type { sdkConfigSchema } from '../cli-config/schema.js';
import { stamp } from '../conv/wire.js';

/** The presence contract; register abstract\u2192concrete and depend on the abstract (DI rule). */
/** The presence contract; register abstract→concrete and depend on the abstract (DI rule). */
export abstract class IAgentPresence {
public abstract readonly instanceId: string;
public abstract readonly world: string;
/** Publish `ready` and start pulsing. Call once, after subscriptions are up (agent-spec). */
public abstract boot(): void;
/** This instance now serves `conversationId` at `cwd`. Re-publish on a `cwd` move (last-write-wins).
* Carries `intervalS` — additive, forwards-compatible — so a consumer who never sees a pulse (a late
* joiner between pulses, a deployment that captures attachment but not liveness) still knows the
* liveness promise being advertised, rather than only learning it from the first heartbeat. */
public abstract attach(conversationId: string, cwd: string): void;
/** Released, deliberately \u2014 Ctrl-C, drain, done. A crash publishes nothing (agent-spec). */
/** Open this instance's claim on `conversationId`: publish `attached` on the conversation's own
* attachment leaf, exactly once per claim (agent-spec, Attachment) — a re-call while the claim is
* open is a no-op, never a second `attached`. Carries the identity pair (world, instanceId), `cwd`,
* the liveness promise `intervalS`, and the conversation's `tip` so an observer knows where the
* conversation stands without replaying the change stream. */
public abstract attach(conversationId: string, cwd: string, tip: string | null): void;
/** The working directory changed under the open claim: publish `moved` — a fact about the standing
* claim, never a second `attached` (conversation-spec, Attachment). No open claim: no-op. */
public abstract move(conversationId: string, cwd: string): void;
/** Released, deliberately — Ctrl-C, drain, done, or standing down after displacement. Publishes
* `detached` and closes the claim; a crash publishes nothing (agent-spec). */
public abstract detach(conversationId: string): void;
/** Stop pulsing \u2014 called once on clean shutdown, after every conversation has detached. */
/** Whether this instance currently holds an open claim on `conversationId` — the gate change
* publishers consult so a displaced instance stops committing (agent-spec, Attachment). */
public abstract hasClaim(conversationId: string): boolean;
/** Stop pulsing — called once on clean shutdown, after every conversation has detached. */
public abstract stop(): void;
}

/**
* The agent concern's telemetry face: `ready` once on boot, a `pulse` liveness promise on an interval,
* and `attached`/`detached` around this instance's conversation binding. `instanceId` is minted fresh
* per process (agent-spec: an instance's lifetime is its own, and a restarted bridge is a new instance in
* the same world). Zero effect when the bus is disabled (IBus.publish is a no-op then).
* The agent's presence on the wire: `ready` once on boot and a `pulse` liveness promise on an interval,
* both on the world's own telemetry tree, plus the conversation attachment claim — `attached`/`moved`/
* `detached` on `conv.v2.{id}.attachment.>`, the conversation's own tree (conversation-spec, Attachment).
* `instanceId` is minted fresh per process (agent-spec: a restarted process is a new instance in the
* same world). Zero effect when the bus is disabled (IBus.publish is a no-op then).
*/
export class AgentPresence extends IAgentPresence {
@dependsOn(IBus) private readonly bus!: IBus;
@dependsOn(Clock) private readonly clock!: Clock;
@dependsOn(ConfigLoader) private readonly configLoader!: ConfigLoader<typeof sdkConfigSchema>;
public readonly instanceId = randomUUID();
#pulse: NodeJS.Timeout | null = null;
readonly #claims = new Set<string>();

public get world(): string {
return this.configLoader.config.nats.world;
Expand All @@ -50,12 +60,30 @@ export class AgentPresence extends IAgentPresence {
this.#pulse.unref();
}

public attach(conversationId: string, cwd: string): void {
this.bus.publish(`agent.v1.${this.world}.telemetry.attached`, stamp(this.clock, { instanceId: this.instanceId, conversationId, cwd, intervalS: this.configLoader.config.nats.pulseIntervalS }));
public attach(conversationId: string, cwd: string, tip: string | null): void {
if (this.#claims.has(conversationId)) {
return; // exactly once per open claim — a second attached is the violation shape (agent-spec)
}
this.#claims.add(conversationId);
this.bus.publish(`conv.v2.${conversationId}.attachment.attached`, stamp(this.clock, { instanceId: this.instanceId, world: this.world, cwd, tip, intervalS: this.configLoader.config.nats.pulseIntervalS }));
}

public move(conversationId: string, cwd: string): void {
if (!this.#claims.has(conversationId)) {
return;
}
this.bus.publish(`conv.v2.${conversationId}.attachment.moved`, stamp(this.clock, { instanceId: this.instanceId, world: this.world, cwd }));
}

public detach(conversationId: string): void {
this.bus.publish(`agent.v1.${this.world}.telemetry.detached`, stamp(this.clock, { instanceId: this.instanceId, conversationId }));
if (!this.#claims.delete(conversationId)) {
return;
}
this.bus.publish(`conv.v2.${conversationId}.attachment.detached`, stamp(this.clock, { instanceId: this.instanceId, world: this.world }));
}

public hasClaim(conversationId: string): boolean {
return this.#claims.has(conversationId);
}

public stop(): void {
Expand Down
Loading
Loading