@lucascouts/claude-agent-tui 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/NOTICE +1 -1
  2. package/README.md +1 -1
  3. package/dist/acp-agent.d.ts +62 -8
  4. package/dist/acp-agent.js +130 -15
  5. package/dist/agent-catalog.d.ts +0 -1
  6. package/dist/ansi-mirror.d.ts +0 -1
  7. package/dist/besteffort.d.ts +0 -1
  8. package/dist/billing/entrypoint-guard.d.ts +0 -1
  9. package/dist/claude-path.d.ts +0 -1
  10. package/dist/command-catalog.d.ts +84 -0
  11. package/dist/command-catalog.js +339 -0
  12. package/dist/diff-enriched-reader.d.ts +0 -1
  13. package/dist/diff-source.d.ts +0 -1
  14. package/dist/drift-checks.d.ts +0 -1
  15. package/dist/end-of-turn.d.ts +0 -1
  16. package/dist/engine-lifecycle.d.ts +0 -1
  17. package/dist/engine-pty.d.ts +0 -1
  18. package/dist/engine-watcher.d.ts +0 -1
  19. package/dist/engine.d.ts +0 -1
  20. package/dist/event-switch.d.ts +0 -1
  21. package/dist/gate/port.d.ts +0 -1
  22. package/dist/gate/settings-writer.d.ts +0 -1
  23. package/dist/image-input.d.ts +0 -1
  24. package/dist/image-vision-smoke.d.ts +0 -1
  25. package/dist/index.d.ts +0 -1
  26. package/dist/jsonl.d.ts +0 -1
  27. package/dist/lib.d.ts +0 -1
  28. package/dist/linearize.d.ts +1 -2
  29. package/dist/linearize.js +1 -1
  30. package/dist/live-diff-env.d.ts +0 -1
  31. package/dist/live-subagent-env.d.ts +0 -1
  32. package/dist/mcp-config-writer.d.ts +0 -1
  33. package/dist/model-catalog.d.ts +39 -1
  34. package/dist/model-catalog.js +77 -7
  35. package/dist/permissions/allow-inject.d.ts +0 -1
  36. package/dist/permissions/deny.d.ts +12 -1
  37. package/dist/permissions/deny.js +18 -0
  38. package/dist/permissions/elicitation-bridge.d.ts +71 -0
  39. package/dist/permissions/elicitation-bridge.js +146 -0
  40. package/dist/permissions/gate-wiring.d.ts +23 -3
  41. package/dist/permissions/gate-wiring.js +123 -1
  42. package/dist/permissions/hook-server.d.ts +11 -3
  43. package/dist/permissions/hook-server.js +10 -1
  44. package/dist/permissions/permission-mode.d.ts +0 -1
  45. package/dist/permissions/request-permission.d.ts +0 -1
  46. package/dist/settings.d.ts +0 -1
  47. package/dist/stop-reason-map.d.ts +0 -1
  48. package/dist/subagent-gate.d.ts +0 -1
  49. package/dist/subagent-source.d.ts +0 -1
  50. package/dist/subagent-watcher.d.ts +0 -1
  51. package/dist/tools.d.ts +0 -1
  52. package/dist/usage-env.d.ts +0 -1
  53. package/dist/usage.d.ts +0 -1
  54. package/dist/utils.d.ts +0 -1
  55. package/dist/zed-register.d.ts +0 -1
  56. package/package.json +6 -3
  57. package/dist/acp-agent.d.ts.map +0 -1
  58. package/dist/agent-catalog.d.ts.map +0 -1
  59. package/dist/ansi-mirror.d.ts.map +0 -1
  60. package/dist/besteffort.d.ts.map +0 -1
  61. package/dist/billing/entrypoint-guard.d.ts.map +0 -1
  62. package/dist/claude-path.d.ts.map +0 -1
  63. package/dist/diff-enriched-reader.d.ts.map +0 -1
  64. package/dist/diff-source.d.ts.map +0 -1
  65. package/dist/drift-checks.d.ts.map +0 -1
  66. package/dist/end-of-turn.d.ts.map +0 -1
  67. package/dist/engine-lifecycle.d.ts.map +0 -1
  68. package/dist/engine-pty.d.ts.map +0 -1
  69. package/dist/engine-watcher.d.ts.map +0 -1
  70. package/dist/engine.d.ts.map +0 -1
  71. package/dist/event-switch.d.ts.map +0 -1
  72. package/dist/gate/port.d.ts.map +0 -1
  73. package/dist/gate/settings-writer.d.ts.map +0 -1
  74. package/dist/image-input.d.ts.map +0 -1
  75. package/dist/image-vision-smoke.d.ts.map +0 -1
  76. package/dist/index.d.ts.map +0 -1
  77. package/dist/jsonl.d.ts.map +0 -1
  78. package/dist/lib.d.ts.map +0 -1
  79. package/dist/linearize.d.ts.map +0 -1
  80. package/dist/live-diff-env.d.ts.map +0 -1
  81. package/dist/live-subagent-env.d.ts.map +0 -1
  82. package/dist/mcp-config-writer.d.ts.map +0 -1
  83. package/dist/model-catalog.d.ts.map +0 -1
  84. package/dist/permissions/allow-inject.d.ts.map +0 -1
  85. package/dist/permissions/deny.d.ts.map +0 -1
  86. package/dist/permissions/gate-wiring.d.ts.map +0 -1
  87. package/dist/permissions/hook-server.d.ts.map +0 -1
  88. package/dist/permissions/permission-mode.d.ts.map +0 -1
  89. package/dist/permissions/request-permission.d.ts.map +0 -1
  90. package/dist/settings.d.ts.map +0 -1
  91. package/dist/stop-reason-map.d.ts.map +0 -1
  92. package/dist/subagent-gate.d.ts.map +0 -1
  93. package/dist/subagent-source.d.ts.map +0 -1
  94. package/dist/subagent-watcher.d.ts.map +0 -1
  95. package/dist/tools.d.ts.map +0 -1
  96. package/dist/usage-env.d.ts.map +0 -1
  97. package/dist/usage.d.ts.map +0 -1
  98. package/dist/utils.d.ts.map +0 -1
  99. package/dist/zed-register.d.ts.map +0 -1
package/NOTICE CHANGED
@@ -10,5 +10,5 @@ This product is a derivative work of:
10
10
 
11
11
  The engine was rewritten to drive the Claude Code subscription TUI over a
12
12
  pseudo-terminal (PTY) instead of calling the Claude Agent SDK. See
13
- .fork-provenance.json (fork point: v0.39.0) and CHANGELOG.md for the scope
13
+ .fork-provenance.json (fork point: v0.53.0) and CHANGELOG.md for the scope
14
14
  of modifications.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  An [ACP](https://agentclientprotocol.com)-compatible agent that drives the **Claude Code subscription TUI** over a PTY, so your Claude Code threads render natively in [Zed](https://zed.dev) and other ACP clients.
6
6
 
7
- > **Fork** of [`@agentclientprotocol/claude-agent-acp`](https://github.com/agentclientprotocol/claude-agent-acp) v0.39.0. Where the upstream adapter calls the Claude Agent **SDK**, this fork spawns the `claude` **subscription CLI** in a pseudo-terminal and translates its JSONL transcript into ACP `session/update` notifications. See [`.fork-provenance.json`](.fork-provenance.json) for the exact fork point.
7
+ > **Fork** of [`@agentclientprotocol/claude-agent-acp`](https://github.com/agentclientprotocol/claude-agent-acp) v0.53.0. Where the upstream adapter calls the Claude Agent **SDK**, this fork spawns the `claude` **subscription CLI** in a pseudo-terminal and translates its JSONL transcript into ACP `session/update` notifications. See [`.fork-provenance.json`](.fork-provenance.json) for the exact fork point.
8
8
 
9
9
  ## Why this exists
10
10
 
@@ -1,4 +1,4 @@
1
- import { Agent, AgentSideConnection, AuthenticateRequest, CancelNotification, ClientCapabilities, ForkSessionRequest, ForkSessionResponse, InitializeRequest, InitializeResponse, ListSessionsRequest, ListSessionsResponse, LoadSessionRequest, LoadSessionResponse, NewSessionRequest, NewSessionResponse, PromptRequest, PromptResponse, ReadTextFileRequest, ReadTextFileResponse, ResumeSessionRequest, ResumeSessionResponse, SessionConfigOption, SessionModeState, SessionNotification, SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, SetSessionModeRequest, SetSessionModeResponse, CloseSessionRequest, CloseSessionResponse, DeleteSessionRequest, DeleteSessionResponse, TerminalHandle, TerminalOutputResponse, WriteTextFileRequest, WriteTextFileResponse } from "@agentclientprotocol/sdk";
1
+ import { Agent, AgentSideConnection, AuthenticateRequest, CancelNotification, ClientCapabilities, ForkSessionRequest, ForkSessionResponse, InitializeRequest, InitializeResponse, ListSessionsRequest, ListSessionsResponse, LoadSessionRequest, LoadSessionResponse, NewSessionRequest, NewSessionResponse, PromptRequest, PromptResponse, ReadTextFileRequest, ReadTextFileResponse, ResumeSessionRequest, ResumeSessionResponse, SessionConfigOption, SessionModeState, SessionNotification, SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, SetSessionModeRequest, SetSessionModeResponse, CloseSessionRequest, CloseSessionResponse, DeleteSessionRequest, DeleteSessionResponse, LogoutRequest, LogoutResponse, TerminalHandle, TerminalOutputResponse, WriteTextFileRequest, WriteTextFileResponse, AvailableCommand } from "@agentclientprotocol/sdk";
2
2
  import { ModelInfo, Options, PermissionMode, PermissionUpdate, SDKMessageOrigin, SDKPartialAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
3
3
  import { ContentBlockParam } from "@anthropic-ai/sdk/resources";
4
4
  import { BetaContentBlock, BetaRawContentBlockDelta } from "@anthropic-ai/sdk/resources/beta.mjs";
@@ -31,6 +31,7 @@ type AccumulatedUsage = {
31
31
  cachedReadTokens: number;
32
32
  cachedWriteTokens: number;
33
33
  };
34
+ export declare const DEFAULT_CONTEXT_WINDOW = 200000;
34
35
  type Session = {
35
36
  /** The live PTY handle (story 013/014) running the subscription `claude` TUI for this session. */
36
37
  pty: IPty;
@@ -107,11 +108,14 @@ type Session = {
107
108
  */
108
109
  agents?: AgentCatalogEntry[];
109
110
  configOptions: SessionConfigOption[];
110
- /** Context window size of the last top-level assistant model, carried across
111
- * prompts so mid-stream usage_update notifications report a correct `size`
112
- * before the turn's first result message arrives. Defaults to
113
- * DEFAULT_CONTEXT_WINDOW, refreshed from each result's modelUsage, and
114
- * invalidated when the user switches the session's model. */
111
+ /** Context window size for the session, carried across prompts so mid-stream
112
+ * usage_update notifications report a correct `size` before the turn's first
113
+ * result message arrives. Seeded by `inferContextWindowFromModel` (the static
114
+ * `MODEL_CONTEXT_WINDOWS` curation) and re-resolved when the user switches the
115
+ * session's model. NOTE (story 068): there is NO `result.modelUsage` refresh —
116
+ * the JSONL `usage` block carries only token counts, never a window; the window
117
+ * comes from static curation (the Models API `max_input_tokens` is the real
118
+ * authority, which this PTY/JSONL fork does not call). */
115
119
  contextWindowSize: number;
116
120
  /** Accumulated task list for the session, keyed by task ID. Task IDs are
117
121
  * per-session, so this state must not be shared across sessions. */
@@ -415,6 +419,13 @@ export interface AgentDeps {
415
419
  * `~/.claude/agents`. Production passes nothing → the real disk glob.
416
420
  */
417
421
  discoverAgents?: (cwd: string) => AgentCatalogEntry[];
422
+ /**
423
+ * Story 063 (R1/R1.1) — override the offline command discovery `sendAvailableCommandsUpdate` sources
424
+ * the `available_commands_update` set from (default: the disk-only {@link discoverCommands}, keyed on
425
+ * the session cwd). Injected by the wiring test with an in-memory fake so the surface is exercised
426
+ * hermetically, never touching the real `~/.claude`. Production passes nothing → the real disk scan.
427
+ */
428
+ discoverCommands?: (cwd: string) => AvailableCommand[];
418
429
  /**
419
430
  * Story 056 (#812) — override the SDK session-metadata reader the end-of-turn `session_info_update`
420
431
  * push sources the title from (default: the pure {@link getSessionInfo} from the agent SDK, which
@@ -560,6 +571,14 @@ export declare class ClaudeAcpAgent implements Agent {
560
571
  [key: string]: BackgroundTerminal;
561
572
  };
562
573
  clientCapabilities?: ClientCapabilities;
574
+ /**
575
+ * Story 065 (R1/R3) — did the client advertise `clientCapabilities.elicitation.form`
576
+ * at initialize? Presence-based (a present `form` may legitimately be an empty `{}`,
577
+ * so this is derived with `!= null`, NOT property truthiness). The 065 gate (task 3.1)
578
+ * reads this to decide relay-via-elicitation (R1) vs the story-064 deny fallback (R3).
579
+ * Defaults `false` so a client that never advertised elicitation falls back safely.
580
+ */
581
+ clientSupportsElicitationForm: boolean;
563
582
  logger: Logger;
564
583
  gatewayAuthRequest?: GatewayAuthRequest;
565
584
  engine: Engine;
@@ -587,6 +606,8 @@ export declare class ClaudeAcpAgent implements Agent {
587
606
  private readonly gateOptions?;
588
607
  /** Story 056 (R3.2) — main-thread agent-persona discovery seam; see {@link AgentDeps.discoverAgents}. */
589
608
  private readonly discoverAgents;
609
+ /** Story 063 (R1/R1.1) — offline `available_commands` discovery seam; see {@link AgentDeps.discoverCommands}. */
610
+ private readonly discoverCommands;
590
611
  /** Story 056 (#812) — SDK session-metadata reader for the end-of-turn title push; see
591
612
  * {@link AgentDeps.getSessionInfo}. */
592
613
  private readonly getSessionInfo;
@@ -600,6 +621,17 @@ export declare class ClaudeAcpAgent implements Agent {
600
621
  loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse>;
601
622
  listSessions(params: ListSessionsRequest): Promise<ListSessionsResponse>;
602
623
  authenticate(_params: AuthenticateRequest): Promise<void>;
624
+ /**
625
+ * ACP `logout` (acp-sdk 1.0.0, acp.d.ts:1646). Under the PTY engine the bridge
626
+ * authenticates lazily and only tracks an in-memory `gatewayAuthRequest`; the
627
+ * interactive `claude` TUI owns the on-disk credential lifecycle. So `logout`
628
+ * here drops the in-memory auth intent and re-offers a clean handshake on the
629
+ * next `initialize()` (authMethods are recomputed there, unconditioned by this
630
+ * field). It does NOT read/write/delete `~/.claude` (billing seam — story 062
631
+ * R2) and never bridges `/logout` to the PTY (R3). Idempotent with no prior
632
+ * authenticate() (R4); active sessions are untouched (R6).
633
+ */
634
+ logout(_params: LogoutRequest): Promise<LogoutResponse | void>;
603
635
  prompt(params: PromptRequest): Promise<PromptResponse>;
604
636
  /**
605
637
  * Story 056 (#812) — push the sanitized session title to the client via `session_info_update`,
@@ -617,7 +649,7 @@ export declare class ClaudeAcpAgent implements Agent {
617
649
  /** Tear down all active sessions. Called when the ACP connection closes. */
618
650
  dispose(): Promise<void>;
619
651
  closeSession(params: CloseSessionRequest): Promise<CloseSessionResponse>;
620
- unstable_deleteSession(params: DeleteSessionRequest): Promise<DeleteSessionResponse>;
652
+ deleteSession(params: DeleteSessionRequest): Promise<DeleteSessionResponse>;
621
653
  setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse>;
622
654
  setSessionConfigOption(params: SetSessionConfigOptionRequest): Promise<SetSessionConfigOptionResponse>;
623
655
  /**
@@ -895,5 +927,27 @@ export declare function runAcp(deps?: AgentDeps): {
895
927
  connection: AgentSideConnection;
896
928
  agent: ClaudeAcpAgent;
897
929
  };
930
+ /** Resolve a model alias's context window (the usage_update `size` denominator).
931
+ * NOTE (story 068): there is NO `result.modelUsage` window to refresh from — the
932
+ * JSONL `usage` carries only token counts; the window comes from static curation
933
+ * (the Models API `max_input_tokens` is the real authority, which this fork does
934
+ * not call), as detailed below.
935
+ *
936
+ * Story 068 (R1, R1.1, R1.2): consults the static {@link MODEL_CONTEXT_WINDOWS}
937
+ * alias→window map FIRST (an exact catalog-`value` hit — `opus`=1M, `sonnet`=200K,
938
+ * `sonnet[1m]`=1M, `haiku`=200K, `default`/`opusplan`=200K conservative). This
939
+ * fixes `opus` having wrongly reported 200K. An alias absent from the map then
940
+ * falls back to the legacy `\b1m\b` inference: Anthropic 1M-context variants
941
+ * encode "1m" as a distinct token in the SDK model ID (e.g., "claude-opus-4-6-1m"),
942
+ * which `\b1m\b` catches without also matching "10m" or embedded substrings.
943
+ * `null` (fully unknown) is intentional — the two call sites apply
944
+ * `?? DEFAULT_CONTEXT_WINDOW`. */
945
+ export declare function inferContextWindowFromModel(model: string): number | null;
946
+ /** Story 069 (R1) — AUTHORITATIVE context window from a turn's REAL model ID (the JSONL `model`
947
+ * field), used by the pump to refine the alias seed once the model is known. Exact-ID lookup first
948
+ * (MODEL_ID_CONTEXT_WINDOWS), then a family+version heuristic for dated snapshots / future variants
949
+ * (Opus is NOT uniform: 4.6 and earlier = 200K, 4.7+ = 1M; Sonnet 4.x = 200K but Sonnet 5+ = 1M;
950
+ * haiku = 200K; fable = 1M — story 071), then a
951
+ * `\b1m\b` suffix, then null (R1.3: a missing / non-string id never refines). */
952
+ export declare function inferContextWindowFromModelId(id: string): number | null;
898
953
  export {};
899
- //# sourceMappingURL=acp-agent.d.ts.map
package/dist/acp-agent.js CHANGED
@@ -24,12 +24,16 @@ import { guardEvent } from "./billing/entrypoint-guard.js";
24
24
  import { usageUpdatesFor } from "./usage.js";
25
25
  import { createTurnResolver } from "./end-of-turn.js";
26
26
  import { sendPrompt } from "./engine-pty.js";
27
- import { MODEL_CATALOG, DEFAULT_MODEL_INFO, ULTRACODE_EFFORT, ULTRACODE_EFFORT_LEVEL, ULTRACODE_EFFORT_LABEL, } from "./model-catalog.js";
27
+ import { MODEL_CATALOG, MODEL_CONTEXT_WINDOWS, MODEL_ID_CONTEXT_WINDOWS, modelSelectorDescription, DEFAULT_MODEL_INFO, ULTRACODE_EFFORT, ULTRACODE_EFFORT_LEVEL, ULTRACODE_EFFORT_LABEL, } from "./model-catalog.js";
28
28
  // Story 060 (R2.2/R2.3/R3.2) — the declarative spawn-time complement to the live ultracode keyword:
29
29
  // toggle the {ultracode,ultracodeKeywordTrigger} keys in the gate's per-session scratch settings file
30
30
  // (preserving the hook + every other key). Lives in the gate's settings-writer so it reuses durableWrite.
31
31
  import { applyUltracodeSettings } from "./gate/settings-writer.js";
32
32
  import { discoverAgents } from "./agent-catalog.js";
33
+ // Story 063 (R1) — OFFLINE disk discovery of the `available_commands` set (custom slash-commands +
34
+ // skills + enabled-plugin surfaces + built-ins), keyed on the session cwd. Populates the
35
+ // `available_commands_update` the session emits at creation in place of the old unconditional `[]`.
36
+ import { discoverCommands } from "./command-catalog.js";
33
37
  import { setupSessionGate } from "./permissions/gate-wiring.js";
34
38
  // Story 057 / Task 2.3 — MCP scratch-file lifecycle (translate ACP servers → claude `--mcp-config`
35
39
  // JSON, durable 0600 write, idempotent teardown removal). Mirrors the gate's settings-scratch
@@ -50,7 +54,7 @@ function sanitizeTitle(text) {
50
54
  }
51
55
  return sanitized.slice(0, MAX_TITLE_LENGTH - 1) + "…";
52
56
  }
53
- const DEFAULT_CONTEXT_WINDOW = 200000;
57
+ export const DEFAULT_CONTEXT_WINDOW = 200000;
54
58
  /**
55
59
  * No-op {@link IPty} stub for the Degrau-1 replay-only load path: there is no live `claude` process,
56
60
  * but the session record's `pty` field is typed `IPty`. Every method is inert — teardown's `kill()`
@@ -532,6 +536,14 @@ function deriveSubagentLabel(messages, parentId) {
532
536
  export class ClaudeAcpAgent {
533
537
  constructor(client, logger, engine = createStubEngine(), deps = {}) {
534
538
  this.backgroundTerminals = {};
539
+ /**
540
+ * Story 065 (R1/R3) — did the client advertise `clientCapabilities.elicitation.form`
541
+ * at initialize? Presence-based (a present `form` may legitimately be an empty `{}`,
542
+ * so this is derived with `!= null`, NOT property truthiness). The 065 gate (task 3.1)
543
+ * reads this to decide relay-via-elicitation (R1) vs the story-064 deny fallback (R3).
544
+ * Defaults `false` so a client that never advertised elicitation falls back safely.
545
+ */
546
+ this.clientSupportsElicitationForm = false;
535
547
  /** Live PTY-engine registry shared with the per-session engines (story 014 cleanup map). */
536
548
  this.engines = new Map();
537
549
  this.sessions = {};
@@ -571,12 +583,27 @@ export class ClaudeAcpAgent {
571
583
  // Story 056 (R3.2): main-thread agent-persona discovery — defaults to the glob-only
572
584
  // discoverAgents; tests inject an in-memory fake so the `agent` surface is hermetic.
573
585
  this.discoverAgents = deps.discoverAgents ?? discoverAgents;
586
+ // Story 063 (R1/R1.1): offline `available_commands` discovery — defaults to the disk-only
587
+ // discoverCommands; tests inject an in-memory fake so the surface is hermetic (no real ~/.claude read).
588
+ this.discoverCommands = deps.discoverCommands ?? discoverCommands;
574
589
  // Story 056 (#812): end-of-turn session_info_update title source — defaults to the pure SDK
575
590
  // getSessionInfo; tests inject an in-memory fake so the push is hermetic (no real ~/.claude read).
576
591
  this.getSessionInfo = deps.getSessionInfo ?? getSessionInfo;
577
592
  }
578
593
  async initialize(request) {
579
594
  this.clientCapabilities = request.clientCapabilities;
595
+ // Story 065 (R1/R3): capability negotiation — a present (non-null) `elicitation.form`
596
+ // means the client supports form elicitation. Presence-based detection is deliberate: both
597
+ // `undefined` and `null` are unsupported, and an empty `{}` `form` IS supported (the UNSTABLE
598
+ // ElicitationFormCapabilities type carries only an optional `_meta`, so a present `form` is
599
+ // legitimately `{}` — detection MUST be presence-based, not truthiness). Written as explicit
600
+ // `!== undefined && !== null` (not `!= null`) to satisfy the eqeqeq lint rule.
601
+ const elicitationForm = request.clientCapabilities?.elicitation?.form;
602
+ this.clientSupportsElicitationForm = elicitationForm !== undefined && elicitationForm !== null;
603
+ // Story 065 (Task 6.1 live-probe): make the negotiated capability observable in the Zed logs so
604
+ // the in-Zed verdict (form rendered vs. gated-dormant behind the 064 deny) is deterministic. Goes
605
+ // to STDERR via logger.error — NEVER stdout, which carries the ACP ndJson stream.
606
+ this.logger.error(`[065] clientCapabilities.elicitation.form advertised: ${this.clientSupportsElicitationForm}`);
580
607
  // Bypasses standard auth by routing requests through a custom Anthropic-protocol gateway.
581
608
  // Only offered when the client advertises `auth._meta.gateway` capability.
582
609
  const supportsGatewayAuth = request.clientCapabilities?.auth?._meta?.gateway === true;
@@ -780,6 +807,19 @@ export class ClaudeAcpAgent {
780
807
  }
781
808
  throw new Error("Method not implemented.");
782
809
  }
810
+ /**
811
+ * ACP `logout` (acp-sdk 1.0.0, acp.d.ts:1646). Under the PTY engine the bridge
812
+ * authenticates lazily and only tracks an in-memory `gatewayAuthRequest`; the
813
+ * interactive `claude` TUI owns the on-disk credential lifecycle. So `logout`
814
+ * here drops the in-memory auth intent and re-offers a clean handshake on the
815
+ * next `initialize()` (authMethods are recomputed there, unconditioned by this
816
+ * field). It does NOT read/write/delete `~/.claude` (billing seam — story 062
817
+ * R2) and never bridges `/logout` to the PTY (R3). Idempotent with no prior
818
+ * authenticate() (R4); active sessions are untouched (R6).
819
+ */
820
+ async logout(_params) {
821
+ this.gatewayAuthRequest = undefined;
822
+ }
783
823
  async prompt(params) {
784
824
  const sessionRecord = this.sessions[params.sessionId];
785
825
  if (!sessionRecord) {
@@ -983,6 +1023,8 @@ export class ClaudeAcpAgent {
983
1023
  }
984
1024
  /** Tear down all active sessions. Called when the ACP connection closes. */
985
1025
  async dispose() {
1026
+ // Drop the in-memory auth intent on teardown (story 062 R7) — same clear as logout().
1027
+ this.gatewayAuthRequest = undefined;
986
1028
  await Promise.all(Object.keys(this.sessions).map((id) => this.teardownSession(id)));
987
1029
  }
988
1030
  async closeSession(params) {
@@ -992,7 +1034,7 @@ export class ClaudeAcpAgent {
992
1034
  await this.teardownSession(params.sessionId);
993
1035
  return {};
994
1036
  }
995
- async unstable_deleteSession(params) {
1037
+ async deleteSession(params) {
996
1038
  // Tear down any active in-memory state first so the on-disk file isn't
997
1039
  // recreated by an outstanding query writing to it.
998
1040
  if (this.sessions[params.sessionId]) {
@@ -1585,6 +1627,14 @@ export class ClaudeAcpAgent {
1585
1627
  // in pumpUpdates, so the replay-only load never emitted it).
1586
1628
  if (session && !session.usageDisabled) {
1587
1629
  const carrier = turn.message.message ?? {};
1630
+ // Story 069 (R1) — refine the window from the turn's REAL model (the JSONL `model`), authoritatively
1631
+ // correcting the alias seed (e.g. default → claude-opus-4-8[1m] → 1M). A missing / non-string model or
1632
+ // an unknown id leaves the current value unchanged (R1.3 — never overwrite with null).
1633
+ const realModel = carrier.model;
1634
+ if (typeof realModel === "string" && realModel.length > 0) {
1635
+ session.contextWindowSize =
1636
+ inferContextWindowFromModelId(realModel) ?? session.contextWindowSize;
1637
+ }
1588
1638
  for (const usageUpdate of usageUpdatesFor(carrier, {
1589
1639
  usageUpdate: this.usageUpdate,
1590
1640
  contextWindowSize: session.contextWindowSize,
@@ -1914,15 +1964,26 @@ export class ClaudeAcpAgent {
1914
1964
  const session = this.sessions[sessionId];
1915
1965
  if (!session)
1916
1966
  return;
1917
- // === SEAM(023) Group 1: read-only Degrau-1 shim — emit a static (empty) command set. The SDK
1918
- // `query.supportedCommands()` is dropped; slash commands are owned by the interactive TUI in
1919
- // Degrau-1 and are not enumerable over the read-only JSONL path.
1920
- // Degrau 2 (030/032): PTY-backed control surface the TUI's real command set. ===
1967
+ // === SEAM(023) Group 1: `available_commands_update`. Historically the SDK
1968
+ // `query.supportedCommands()` was dropped (slash commands are owned by the interactive TUI and are
1969
+ // not enumerable over the read-only JSONL path), so Degrau-1 emitted a static empty set.
1970
+ // Story 063 (R1/R1.1) now POPULATES this set OFFLINE from disk `discoverCommands(session.cwd)`
1971
+ // scans the cwd/user `.claude/{commands,skills}`, the enabled-plugin surfaces, and the built-in
1972
+ // tier — instead of the unconditional `[]`. Discovery is SYNCHRONOUS but the 4 call-sites invoke
1973
+ // this method fire-and-forget (`setTimeout(0)`), so it never blocks session creation (R4).
1974
+ // Degrau 2 (030/032): PTY-backed control — surface the TUI's real live command set. ===
1975
+ let availableCommands;
1976
+ try {
1977
+ availableCommands = this.discoverCommands(session.cwd);
1978
+ }
1979
+ catch {
1980
+ availableCommands = []; // R4 — discovery must NEVER crash the session; degrade to []
1981
+ }
1921
1982
  await this.client.sessionUpdate({
1922
1983
  sessionId,
1923
1984
  update: {
1924
1985
  sessionUpdate: "available_commands_update",
1925
- availableCommands: [],
1986
+ availableCommands,
1926
1987
  },
1927
1988
  });
1928
1989
  }
@@ -2113,6 +2174,11 @@ export class ClaudeAcpAgent {
2113
2174
  gate = await setupSessionGate({
2114
2175
  ...this.gateOptions,
2115
2176
  client: this.client,
2177
+ // Story 065 (R1/R3): negotiated in initialize() from clientCapabilities.elicitation.form. When
2178
+ // true the gate drives AskUserQuestion through a real ACP form elicitation; when false it keeps
2179
+ // the story-064 fail-closed deny-guard. this.client (AgentSideConnection) already satisfies the
2180
+ // broadened client type (it has unstable_createElicitation).
2181
+ clientSupportsElicitationForm: this.clientSupportsElicitationForm,
2116
2182
  onWarn: (m) => this.logger.error(m),
2117
2183
  });
2118
2184
  }
@@ -2326,7 +2392,9 @@ function buildConfigOptions(modes, currentModelId, modelInfos, currentEffortLeve
2326
2392
  options: modelInfos.map((m) => ({
2327
2393
  value: m.value,
2328
2394
  name: m.displayName,
2329
- description: m.description ?? undefined,
2395
+ // Story 072 — prepend the version/context label ("Opus 4.8 with 1M context · <tagline>"),
2396
+ // mirroring the live `/model` picker; bare tagline when no label (e.g. opusplan).
2397
+ description: modelSelectorDescription(m) || undefined,
2330
2398
  })),
2331
2399
  },
2332
2400
  ];
@@ -2955,13 +3023,60 @@ export function runAcp(deps) {
2955
3023
  }, stream);
2956
3024
  return { connection, agent };
2957
3025
  }
2958
- /** Best-effort first guess of a model's context window from its ID, used only
2959
- * until a `result` message arrives with the authoritative `modelUsage` value.
2960
- * Anthropic 1M-context variants encode "1m" as a distinct token in the SDK
2961
- * model ID (e.g., "claude-opus-4-6-1m"), which `\b1m\b` catches without also
2962
- * matching things like "10m" or embedded substrings. */
2963
- function inferContextWindowFromModel(model) {
3026
+ /** Resolve a model alias's context window (the usage_update `size` denominator).
3027
+ * NOTE (story 068): there is NO `result.modelUsage` window to refresh from the
3028
+ * JSONL `usage` carries only token counts; the window comes from static curation
3029
+ * (the Models API `max_input_tokens` is the real authority, which this fork does
3030
+ * not call), as detailed below.
3031
+ *
3032
+ * Story 068 (R1, R1.1, R1.2): consults the static {@link MODEL_CONTEXT_WINDOWS}
3033
+ * alias→window map FIRST (an exact catalog-`value` hit — `opus`=1M, `sonnet`=200K,
3034
+ * `sonnet[1m]`=1M, `haiku`=200K, `default`/`opusplan`=200K conservative). This
3035
+ * fixes `opus` having wrongly reported 200K. An alias absent from the map then
3036
+ * falls back to the legacy `\b1m\b` inference: Anthropic 1M-context variants
3037
+ * encode "1m" as a distinct token in the SDK model ID (e.g., "claude-opus-4-6-1m"),
3038
+ * which `\b1m\b` catches without also matching "10m" or embedded substrings.
3039
+ * `null` (fully unknown) is intentional — the two call sites apply
3040
+ * `?? DEFAULT_CONTEXT_WINDOW`. */
3041
+ export function inferContextWindowFromModel(model) {
3042
+ const mapped = MODEL_CONTEXT_WINDOWS[model];
3043
+ if (mapped !== undefined)
3044
+ return mapped; // exact alias hit (!== undefined, NOT truthiness)
2964
3045
  if (/\b1m\b/i.test(model))
3046
+ return 1_000_000; // unknown alias that still encodes a 1m token
3047
+ return null; // caller applies ?? DEFAULT_CONTEXT_WINDOW
3048
+ }
3049
+ /** Story 069 (R1) — AUTHORITATIVE context window from a turn's REAL model ID (the JSONL `model`
3050
+ * field), used by the pump to refine the alias seed once the model is known. Exact-ID lookup first
3051
+ * (MODEL_ID_CONTEXT_WINDOWS), then a family+version heuristic for dated snapshots / future variants
3052
+ * (Opus is NOT uniform: 4.6 and earlier = 200K, 4.7+ = 1M; Sonnet 4.x = 200K but Sonnet 5+ = 1M;
3053
+ * haiku = 200K; fable = 1M — story 071), then a
3054
+ * `\b1m\b` suffix, then null (R1.3: a missing / non-string id never refines). */
3055
+ export function inferContextWindowFromModelId(id) {
3056
+ if (typeof id !== "string" || id.length === 0)
3057
+ return null;
3058
+ const exact = MODEL_ID_CONTEXT_WINDOWS[id];
3059
+ if (exact !== undefined)
3060
+ return exact;
3061
+ // An explicit long-context `[1m]`/`-1m` suffix wins over the family heuristic
3062
+ // (`claude-sonnet-…[1m]` = 1M, not 200K; `/model default` resolves to `claude-opus-4-8[1m]`).
3063
+ if (/\b1m\b/i.test(id))
3064
+ return 1_000_000;
3065
+ const opus = id.match(/claude-opus-(\d+)-(\d+)/);
3066
+ if (opus) {
3067
+ const major = Number(opus[1]);
3068
+ const minor = Number(opus[2]);
3069
+ return major > 4 || (major === 4 && minor >= 7) ? 1_000_000 : 200_000;
3070
+ }
3071
+ if (/claude-fable/.test(id))
2965
3072
  return 1_000_000;
3073
+ // Sonnet is NOT uniform across generations (story 071): the subscription CLI serves Sonnet 4.x
3074
+ // at 200K but Sonnet 5+ natively at 1M (Sonnet 5 has no smaller context variant). Version-aware,
3075
+ // like the Opus 4-6 vs 4-7/4-8 split above; dated snapshots (`claude-sonnet-5-<date>`) match too.
3076
+ const sonnet = id.match(/claude-sonnet-(\d+)/);
3077
+ if (sonnet)
3078
+ return Number(sonnet[1]) >= 5 ? 1_000_000 : 200_000;
3079
+ if (/claude-haiku/.test(id))
3080
+ return 200_000;
2966
3081
  return null;
2967
3082
  }
@@ -93,4 +93,3 @@ export declare function parseFrontmatter(content: string): Frontmatter;
93
93
  */
94
94
  export declare function discoverAgents(cwd: string, deps?: DiscoverAgentsDeps): AgentCatalogEntry[];
95
95
  export {};
96
- //# sourceMappingURL=agent-catalog.d.ts.map
@@ -39,4 +39,3 @@ export interface AnsiMirrorOptions {
39
39
  * sequence the JSONL tail produces.
40
40
  */
41
41
  export declare function attachAnsiMirror(p: Pick<IPty, "onData">, opts?: AnsiMirrorOptions): IDisposable | undefined;
42
- //# sourceMappingURL=ansi-mirror.d.ts.map
@@ -41,4 +41,3 @@ export declare function translateBestEffort(content: string | ContentBlock[], ro
41
41
  translate?: TranslateFn;
42
42
  }): SessionNotification[];
43
43
  export {};
44
- //# sourceMappingURL=besteffort.d.ts.map
@@ -94,4 +94,3 @@ export declare function guardEvent(event: WatchedMessage, hooks: GuardHooks): Gu
94
94
  * is the spawn helper's anti-recusa concern, distinct from this billing check — §10).
95
95
  */
96
96
  export declare function assertCleanBillingEnv(env: Record<string, string | undefined>): void;
97
- //# sourceMappingURL=entrypoint-guard.d.ts.map
@@ -52,4 +52,3 @@ export interface ResolveOptions {
52
52
  * @throws Error naming the PATH lookup attempt if no executable `claude` is found.
53
53
  */
54
54
  export declare function resolveClaudePath(opts?: ResolveOptions): string;
55
- //# sourceMappingURL=claude-path.d.ts.map
@@ -0,0 +1,84 @@
1
+ import type { AvailableCommand } from "@agentclientprotocol/sdk";
2
+ /**
3
+ * The curated built-in slash-command tier (R9) — the interactive `claude` TUI's own built-ins, which are
4
+ * baked into the binary and therefore NOT disk-discoverable. This is a CURATED APPROXIMATION (C1), NOT a
5
+ * live probe: it stands beside the same static-curation idea as `MODEL_CATALOG`. It is the LOWEST-
6
+ * precedence tier, so any disk command/skill of the same name shadows a built-in (R1/R9). Names are
7
+ * lowercase (R3) and every entry carries a non-empty `description` (the SDK requires it). The list is
8
+ * intentionally small and conservative — a documented approximation, not an exhaustive enumeration.
9
+ */
10
+ export declare const BUILTIN_COMMANDS: readonly AvailableCommand[];
11
+ /**
12
+ * Injectable seams for {@link discoverCommands} — defaults wire the `node:` stdlib and the built-in
13
+ * tier. Tests pass fakes so discovery is exercised against an in-memory fs and an isolated (`[]`)
14
+ * built-in tier, never the real disk or the real `~/.claude` (R5).
15
+ */
16
+ export interface DiscoverCommandsDeps {
17
+ /** Resolve the user's home directory (default: `os.homedir`). */
18
+ homedir?: () => string;
19
+ /** List the `*.md` filenames in `dir`; MUST return `[]` when `dir` is missing/unreadable. */
20
+ readdirMd?: (dir: string) => string[];
21
+ /**
22
+ * List the IMMEDIATE sub-directory names of `dir` (for the skills tier — R7); MUST return `[]` when
23
+ * `dir` is missing/unreadable. Default: {@link defaultReaddirDirs}.
24
+ */
25
+ readdirDirs?: (dir: string) => string[];
26
+ /** Read a file's UTF-8 contents (default: `fs.readFileSync(p, "utf8")`). */
27
+ readFile?: (path: string) => string;
28
+ /** The built-in command tier (lowest precedence). Default: {@link BUILTIN_COMMANDS}. */
29
+ builtins?: readonly AvailableCommand[];
30
+ }
31
+ /**
32
+ * The command-name allowlist (R3). A single un-namespaced segment: LOWERCASE letters/digits/underscore/
33
+ * hyphen only (no spaces, no uppercase, no shell metacharacters, no path separators). Slash-command
34
+ * names are conventionally lowercase, so — unlike `agent-catalog.ts`'s {@link SAFE_AGENT_NAME} — this
35
+ * allowlist excludes uppercase.
36
+ */
37
+ export declare const SAFE_COMMAND_NAME: RegExp;
38
+ /** True iff `name` is a non-empty string matching {@link SAFE_COMMAND_NAME} (R3). */
39
+ export declare function isSafeCommandName(name: unknown): name is string;
40
+ /** The frontmatter fields we extract — `description` and (hyphenated) `argument-hint`. */
41
+ interface CommandFrontmatter {
42
+ description?: string;
43
+ argumentHint?: string;
44
+ }
45
+ /**
46
+ * Minimal `---`-fenced frontmatter line-parser (NOT a full YAML dep). Reads the leading `---\n … \n---`
47
+ * block and pulls the `description` and `argument-hint` `key: value` lines; a single pair of surrounding
48
+ * quotes on a value is stripped. The frontmatter key `argument-hint` (a HYPHEN) is returned as the
49
+ * camelCase {@link CommandFrontmatter.argumentHint}. A file whose first line is not `---` yields `{}`.
50
+ * Tiny + pure on purpose, mirroring `agent-catalog.ts`'s `parseFrontmatter` — the command frontmatter we
51
+ * consume is flat `key: value`.
52
+ */
53
+ export declare function parseCommandFrontmatter(content: string): CommandFrontmatter;
54
+ /**
55
+ * Discover the custom slash-commands to advertise via ACP `available_commands` — FULLY OFFLINE (no
56
+ * subprocess, no network).
57
+ *
58
+ * TIERS, highest precedence first:
59
+ * 1. `<cwd>/.claude/commands/*.md` (cwd commands — R1.1 precedence)
60
+ * 2. `<cwd>/.claude/skills/<name>/SKILL.md` (cwd skills — R7)
61
+ * 3. `~/.claude/commands/*.md` (user commands)
62
+ * 4. `~/.claude/skills/<name>/SKILL.md` (user skills — R7)
63
+ * 5. `~/.claude/plugins/marketplaces/<m>/{commands,skills}` (ENABLED-plugin tier — R8; a plugin
64
+ * surface loses a name collision to any higher tier — R8.1)
65
+ * 6. {@link DiscoverCommandsDeps.builtins} (the R9 built-in tier — LOWEST; a built-in loses a name
66
+ * collision to any disk entry)
67
+ *
68
+ * Each command `*.md` file yields `{ name, description, input? }` (R2): `name` = basename sans `.md`,
69
+ * `description` = frontmatter `description` (else `""`), `input: { hint }` from `argument-hint` (omitted
70
+ * when absent). Each skill `<name>/SKILL.md` yields `{ name, description }` (no `input`): `name` = the
71
+ * DIRECTORY name, `description` = the `SKILL.md` frontmatter (R7 / R7.1). Every name is dropped if it
72
+ * fails the R3 {@link SAFE_COMMAND_NAME} allowlist. Names are deduped FIRST-WINS across the ordered
73
+ * surfaces — so a cwd command out-ranks a cwd skill (cmd > skill, same scope), any cwd surface
74
+ * out-ranks any user surface, an enabled-plugin surface out-ranks the built-ins but LOSES to any
75
+ * cwd/user surface (R8.1) — then the merged set is returned FLAT ALPHABETICAL by `name`.
76
+ *
77
+ * Never throws (R3 / R5): a missing/unreadable dir or file yields `[]` for that surface; a skills subdir
78
+ * without a readable `SKILL.md` is skipped.
79
+ *
80
+ * @param cwd the SESSION cwd (project dir) whose `.claude/{commands,skills}` take precedence.
81
+ * @param deps injectable fs/home/builtins seams (default: `node:` stdlib + {@link BUILTIN_COMMANDS}).
82
+ */
83
+ export declare function discoverCommands(cwd: string, deps?: DiscoverCommandsDeps): AvailableCommand[];
84
+ export {};