@platless/axom-cli 0.1.0 → 0.3.13

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 (109) hide show
  1. package/README.md +1 -1
  2. package/dist/authScopes.js +19 -0
  3. package/dist/axom-patch/cli/README.md +110 -0
  4. package/dist/axom-patch/cli/pi-steering.json +37 -0
  5. package/dist/axom-patch/cli/probes/claude-late-async-terminal.mjs +34 -0
  6. package/dist/axom-patch/cli/probes/pi-steering.mjs +80 -0
  7. package/dist/axom-patch/cli/probes/qoder-steering.mjs +89 -0
  8. package/dist/axom-patch/cli/probes/qoder-subagent-origin.mjs +49 -0
  9. package/dist/axom-patch/cli/qoder-background-terminal.md +81 -0
  10. package/dist/axom-patch/cli/qoder-foreground-output.md +41 -0
  11. package/dist/axom-patch/cli/qoder-steering.json +52 -0
  12. package/dist/axom-patch/cli/qoder-steering.md +30 -0
  13. package/dist/axom-patch/cli/qoder-subagent-origin.json +73 -0
  14. package/dist/axom-patch/cli/qoder-subagent-subwork.md +73 -0
  15. package/dist/axom-patch/cli/runtime.json +37 -0
  16. package/dist/axom-patch/cli/steering/claude-steering-js.patch +89 -0
  17. package/dist/axom-patch/cli/steering/claude-steering-types.patch +28 -0
  18. package/dist/axom-patch/cli/steering/codex-steering.patch +57 -0
  19. package/dist/axom-patch/cli/steering/pi-steering.js +79 -0
  20. package/dist/cli.js +9 -1
  21. package/dist/commands/auth.js +4 -13
  22. package/dist/commands/execute.js +4 -4
  23. package/dist/commands/record.js +6 -6
  24. package/dist/commands/struct.js +4 -4
  25. package/dist/commands/workstation.js +2 -2
  26. package/dist/credentials.js +2 -2
  27. package/dist/install/claude/commands.js +13 -13
  28. package/dist/mcp.js +32 -14
  29. package/dist/workstation/acp/acpAgents.js +447 -166
  30. package/dist/workstation/acp/acpClient.js +232 -18
  31. package/dist/workstation/acp/acpSession.js +502 -74
  32. package/dist/workstation/acp/activityBusy.js +43 -0
  33. package/dist/workstation/acp/activityReporting.js +54 -0
  34. package/dist/workstation/acp/adoption.js +107 -49
  35. package/dist/workstation/acp/authUrlSniffer.js +36 -0
  36. package/dist/workstation/acp/contract.js +11 -6
  37. package/dist/workstation/acp/delegationMcp.js +24 -0
  38. package/dist/workstation/acp/draftInterceptor.js +9 -0
  39. package/dist/workstation/acp/managedPatches.js +228 -0
  40. package/dist/workstation/acp/patchAssets.js +63 -0
  41. package/dist/workstation/acp/permission.js +300 -34
  42. package/dist/workstation/acp/qoderStartup.js +45 -0
  43. package/dist/workstation/acp/registry/catalog.js +263 -4
  44. package/dist/workstation/acp/registry/detect.js +55 -0
  45. package/dist/workstation/acp/sessionScope.js +19 -0
  46. package/dist/workstation/acp/sessionUpdateMapper.js +39 -21
  47. package/dist/workstation/acp/terminalLogin.js +197 -0
  48. package/dist/workstation/acp/test/fakeAgent.js +185 -9
  49. package/dist/workstation/acp/validate.js +33 -5
  50. package/dist/workstation/agentSessions.js +532 -44
  51. package/dist/workstation/channels/agent.js +7 -1
  52. package/dist/workstation/channels/computer.js +152 -4
  53. package/dist/workstation/channels/management.js +3 -3
  54. package/dist/workstation/config.js +3 -1
  55. package/dist/workstation/connection/encryptedChannel.js +88 -21
  56. package/dist/workstation/daemon.js +25 -3
  57. package/dist/workstation/desktop/binding.js +36 -0
  58. package/dist/workstation/desktop/bridge.js +212 -0
  59. package/dist/workstation/desktop/flow-control.js +52 -0
  60. package/dist/workstation/desktop/focused-text.js +130 -0
  61. package/dist/workstation/desktop/protocol.js +35 -0
  62. package/dist/workstation/fileOps.js +277 -0
  63. package/dist/workstation/filePaths.js +174 -0
  64. package/dist/workstation/fileWorker.js +81 -0
  65. package/dist/workstation/fileWorkerClient.js +121 -0
  66. package/dist/workstation/oauth/deviceAuthorization.js +21 -12
  67. package/dist/workstation/protocol/agent.js +154 -5
  68. package/dist/workstation/protocol/common.js +6 -0
  69. package/dist/workstation/protocol/computer.js +92 -3
  70. package/dist/workstation/qoderAdapter/backgroundProcess.js +106 -0
  71. package/dist/workstation/qoderAdapter/backgroundTerminal.js +262 -0
  72. package/dist/workstation/qoderAdapter/completedContinuation.js +139 -0
  73. package/dist/workstation/qoderAdapter/contract.js +78 -0
  74. package/dist/workstation/qoderAdapter/foregroundOutput.js +30 -0
  75. package/dist/workstation/qoderAdapter/history.js +87 -0
  76. package/dist/workstation/qoderAdapter/identityIndex.js +191 -0
  77. package/dist/workstation/qoderAdapter/inputQueue.js +42 -0
  78. package/dist/workstation/qoderAdapter/main.js +93 -0
  79. package/dist/workstation/qoderAdapter/mapper.js +117 -0
  80. package/dist/workstation/qoderAdapter/observerPlugin.js +245 -0
  81. package/dist/workstation/qoderAdapter/observerReceiver.js +187 -0
  82. package/dist/workstation/qoderAdapter/permissions.js +61 -0
  83. package/dist/workstation/qoderAdapter/profile.js +93 -0
  84. package/dist/workstation/qoderAdapter/sdkDriver.js +49 -0
  85. package/dist/workstation/qoderAdapter/session.js +319 -0
  86. package/dist/workstation/qoderAdapter/subagentMeta.js +28 -0
  87. package/dist/workstation/qoderAdapter/subwork.js +77 -0
  88. package/dist/workstation/qoderAdapter/taskControl.js +38 -0
  89. package/dist/workstation/qoderAdapter/taskEvent.js +26 -0
  90. package/dist/workstation/qoderAdapter/taskLedger.js +163 -0
  91. package/dist/workstation/qoderAdapter/types.js +1 -0
  92. package/dist/workstation/qoderAdapter/usage.js +37 -0
  93. package/dist/workstation/register.js +4 -0
  94. package/dist/workstation/tools/browser.js +394 -0
  95. package/dist/workstation/tools/client.js +269 -0
  96. package/dist/workstation/tools/core.js +50 -0
  97. package/dist/workstation/tools/files.js +525 -0
  98. package/dist/workstation/tools/keeperProcess.js +114 -0
  99. package/dist/workstation/tools/terminal.js +214 -0
  100. package/dist/workstation/tools/validation.js +50 -0
  101. package/dist/workstation/update/coordinator.js +29 -0
  102. package/dist/workstation/update/npmInstaller.js +2 -0
  103. package/dist/workstation/update/ptyPreflight.js +24 -0
  104. package/dist/workstation/update/taskRegistry.js +44 -20
  105. package/dist/workstation/update/watchdog.js +66 -0
  106. package/package.json +14 -4
  107. package/skills/axom/SKILL.md +55 -17
  108. package/skills/axom-definition/SKILL.md +32 -32
  109. package/skills/axom-platform/SKILL.md +39 -39
@@ -3,8 +3,13 @@ import { randomUUID } from 'node:crypto';
3
3
  import { Readable, Writable } from 'node:stream';
4
4
  import { client, ndJsonStream, PROTOCOL_VERSION, RequestError } from '@agentclientprotocol/sdk';
5
5
  import { sniffAuthUrl } from './authUrlSniffer.js';
6
+ import { metaTerminalAuthArgs, terminalLoginSpecOf } from './terminalLogin.js';
6
7
  import { airCapabilityMeta, asSdkClientCapabilities, AIR_ASYNC_TASKS_CAPABILITY, AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY, DRAFT_SUBAGENTS_CAPABILITY, } from './draftSubagents.js';
7
8
  import { interceptDraftUpdates } from './draftInterceptor.js';
9
+ import { claudeActivityRaw } from './activityReporting.js';
10
+ import { findOnPath, probeEnvironment } from './installProbe.js';
11
+ import { parseQoderProfile, sameProfileIdentity } from '../qoderAdapter/profile.js';
12
+ import { parseTaskObservation, taskEventMatchesHost } from '../qoderAdapter/taskEvent.js';
8
13
  export class UnsupportedNodeVersionError extends Error {
9
14
  }
10
15
  /**
@@ -251,7 +256,7 @@ const STDERR_TAIL_LIMIT = 8_192;
251
256
  */
252
257
  const STDERR_FLUSH_GRACE_MS = 250;
253
258
  /**
254
- * 把子进程的 stderr 接出来:一边原样转发到本进程的 stderr,一边留一份有界的尾巴给启动期的报错带上,
259
+ * 把子进程的 stderr 接出来:普通连接原样转发,托管 MCP 请求后的输出逐行脱敏,并留一份有界的安全尾巴给报错带上,
255
260
  * 一边逐行喂给 {@link sniffAuthUrl} —— 有的 agent(实测 antigravity)不走 `elicitation/create` 递
256
261
  * 登录链接,而是直接打到 stderr 上(见 `authUrlSniffer.ts` 的文件头注释)。
257
262
  *
@@ -269,9 +274,37 @@ const STDERR_FLUSH_GRACE_MS = 250;
269
274
  */
270
275
  function captureChildStderr(stream, present) {
271
276
  if (!stream)
272
- return { text: () => '', flushed: () => Promise.resolve() };
277
+ return { text: () => '', flushed: () => Promise.resolve(), redactSecret: () => { } };
273
278
  let tail = '';
274
279
  let lineBuffer = '';
280
+ let protectedLine = '';
281
+ let droppingLongLine = false;
282
+ const secrets = new Set();
283
+ const forward = (value) => {
284
+ tail = (tail + value).slice(-STDERR_TAIL_LIMIT);
285
+ process.stderr.write(value);
286
+ };
287
+ const forwardProtected = (value) => {
288
+ protectedLine += value;
289
+ let newline;
290
+ while ((newline = protectedLine.indexOf('\n')) >= 0) {
291
+ if (droppingLongLine) {
292
+ droppingLongLine = false;
293
+ }
294
+ else {
295
+ let safe = protectedLine.slice(0, newline + 1);
296
+ for (const secret of secrets)
297
+ safe = safe.replaceAll(secret, '[REDACTED]');
298
+ forward(safe);
299
+ }
300
+ protectedLine = protectedLine.slice(newline + 1);
301
+ }
302
+ if (protectedLine.length > STDERR_TAIL_LIMIT) {
303
+ protectedLine = '';
304
+ droppingLongLine = true;
305
+ forward('[acp] child stderr line omitted\n');
306
+ }
307
+ };
275
308
  // One login can print its own URL more than once — the ordinary shape of an OAuth CLI is "here's
276
309
  // the link" immediately followed by "if your browser didn't open, here it is again", verbatim
277
310
  // the same URL. Without this, that becomes two permanent, identical, un-retractable cards for a
@@ -315,19 +348,40 @@ function captureChildStderr(stream, present) {
315
348
  };
316
349
  stream.on('data', (chunk) => {
317
350
  const text = chunk.toString();
318
- tail = (tail + text).slice(-STDERR_TAIL_LIMIT);
319
- process.stderr.write(chunk);
320
- scanForAuthUrl(text);
351
+ if (secrets.size > 0)
352
+ forwardProtected(text);
353
+ else {
354
+ tail = (tail + text).slice(-STDERR_TAIL_LIMIT);
355
+ process.stderr.write(chunk);
356
+ scanForAuthUrl(text);
357
+ }
321
358
  });
322
359
  // 读端出错(SIGKILL 掉子进程时管子会断)没有谁需要处理,但一个没有监听器的 `'error'` 会把整个
323
360
  // 守护进程掀翻 —— 这行不是防御性代码,是 EventEmitter 的硬性要求。
324
361
  stream.on('error', () => { });
325
362
  // A final unterminated line (no trailing `\n` before the process exits) is still a real line —
326
363
  // scan whatever is left in the buffer once the stream itself says there is no more coming.
327
- void ended.then(() => scanForAuthUrl('\n'));
364
+ void ended.then(() => {
365
+ if (protectedLine && !droppingLongLine) {
366
+ let safe = protectedLine;
367
+ for (const secret of secrets)
368
+ safe = safe.replaceAll(secret, '[REDACTED]');
369
+ forward(safe);
370
+ }
371
+ if (secrets.size === 0)
372
+ scanForAuthUrl('\n');
373
+ });
328
374
  return {
329
375
  text: () => tail,
330
376
  flushed: (ms) => settledWithin(ended, ms).then(() => undefined),
377
+ redactSecret: (secret) => {
378
+ if (!secret)
379
+ return;
380
+ lineBuffer = '';
381
+ secrets.add(secret);
382
+ if (secret.startsWith('Bearer '))
383
+ secrets.add(secret.slice('Bearer '.length));
384
+ },
331
385
  };
332
386
  }
333
387
  /**
@@ -471,17 +525,21 @@ export async function closeAgentTransport(child, connection, graceMs = CHILD_EXI
471
525
  * Dropping rather than repairing: `id` is the value `authenticate` is called with, so inventing
472
526
  * one would trade a missing sign-in method for a broken one.
473
527
  */
474
- function toAuthMethodSnapshot(method) {
528
+ export function toAuthMethodSnapshot(method) {
475
529
  if (typeof method.id !== 'string' || typeof method.name !== 'string')
476
530
  return null;
531
+ // Terminal by shape (spec 2026-09-24 D6): a top-level `type: "terminal"`, or a `_meta
532
+ // ["terminal-auth"]` object from an agent that advertises its terminal login only there.
533
+ const terminal = terminalLoginSpecOf(method) !== undefined;
534
+ const args = 'args' in method && Array.isArray(method.args) ? method.args : terminal ? metaTerminalAuthArgs(method) : undefined;
477
535
  return {
478
536
  id: method.id,
479
537
  name: method.name,
480
- ...('type' in method && method.type === 'terminal' ? { type: 'terminal' } : {}),
538
+ ...(terminal ? { type: 'terminal' } : {}),
481
539
  ...(method.description != null ? { description: method.description } : {}),
482
- // `args` only exists on `AuthMethodTerminal` — an `agent`-type method has no such field, and
483
- // `'args' in method` is false for it, which is exactly "nothing to show" (see `AgentAuthMethod`).
484
- ...('args' in method && Array.isArray(method.args) ? { args: method.args } : {}),
540
+ // `args` only exists on a terminal method — top-level, or under `_meta["terminal-auth"]` when
541
+ // that is the only place the agent put it. Never the `_meta` command: see `terminalLogins`.
542
+ ...(args ? { args } : {}),
485
543
  };
486
544
  }
487
545
  const DEFAULT_INITIALIZE_TIMEOUT_MS = 30_000;
@@ -494,14 +552,19 @@ const DEFAULT_INITIALIZE_TIMEOUT_MS = 30_000;
494
552
  */
495
553
  export async function connectAgent(spec, env, agentEnv, options = {}) {
496
554
  assertNodeVersionSupported(spec, options.nodeVersion);
555
+ const processEnv = { ...process.env, ...spec.env, ...env, ...agentEnv };
497
556
  const child = spawn(spec.command, spec.args, {
498
557
  // stderr 是 `pipe` 而不是 `inherit`,而且**依然不接进协议流** —— 它被 `captureChildStderr`
499
558
  // 单独读走:原样转发到 daemon.log(`inherit` 以前免费提供的那件事),同时留一份尾巴给启动期的
500
559
  // 报错。启动失败时 npm 写在这里的文字是唯一的一手证据。
501
560
  stdio: ['pipe', 'pipe', 'pipe'],
502
- env: { ...process.env, ...spec.env, ...env, ...agentEnv },
561
+ env: processEnv,
503
562
  });
504
563
  const stderrTail = captureChildStderr(child.stderr, options.present);
564
+ for (const secret of options.stderrSecrets ?? [])
565
+ stderrTail.redactSecret(secret);
566
+ if (options.onProcessExit)
567
+ child.once('exit', options.onProcessExit);
505
568
  const lifecycle = watchChildLifecycle(child, spec.command, stderrTail);
506
569
  // Argument order reads backwards from what the names suggest: argument 1 is the child's stdin
507
570
  // (we write to it), argument 2 is the child's stdout (we read from it). Swapping them still
@@ -510,13 +573,50 @@ export async function connectAgent(spec, env, agentEnv, options = {}) {
510
573
  // Filled in by `bindDraftUpdates` once a session exists. Held in closure variables rather than
511
574
  // passed in, because the interceptor has to be in place before `initialize` — the agent may send
512
575
  // its first update before anyone has had a chance to register anything.
576
+ let updateCapture = null;
513
577
  let ownSessionId = null;
578
+ let diagnostics = {
579
+ generation: randomUUID(), revision: 0, available: true,
580
+ launch: { source: options.launchSource ?? 'direct',
581
+ executable: (findOnPath(spec.command, probeEnvironment(processEnv)) ?? spec.command).slice(0, 4096),
582
+ platform: process.platform, architecture: process.arch, transport: 'stdio' },
583
+ };
584
+ const diagnosticListeners = new Set();
585
+ const providerTaskListeners = new Set();
586
+ child.once('exit', () => {
587
+ diagnostics = { ...diagnostics, available: false, revision: diagnostics.revision + 1,
588
+ profile: diagnostics.profile ? { ...diagnostics.profile, observerHealthy: false, rootConfirmed: false } : undefined };
589
+ for (const listener of diagnosticListeners)
590
+ listener(diagnostics);
591
+ });
592
+ let adapterRevision = 0;
593
+ const pendingProfiles = new Map();
594
+ const applyProfile = (sessionId, revision, profile) => {
595
+ const previous = diagnostics.profile;
596
+ if (!previous || !diagnostics.available || sessionId !== ownSessionId || revision <= adapterRevision ||
597
+ !sameProfileIdentity(previous, profile) || profile.rootSessionId !== ownSessionId ||
598
+ (previous.runtimeGeneration !== undefined && profile.runtimeGeneration !== previous.runtimeGeneration) ||
599
+ (profile.rootConfirmed === true && !profile.runtimeGeneration))
600
+ return;
601
+ adapterRevision = revision;
602
+ diagnostics = { ...diagnostics, revision: diagnostics.revision + 1, profile };
603
+ for (const listener of diagnosticListeners)
604
+ listener(diagnostics);
605
+ };
514
606
  let draftSink = null;
607
+ // Same "filled in later" shape as `draftSink`, for `bindActivity` — see that method's own doc
608
+ // comment on `AcpConnection`.
609
+ let activitySink = null;
515
610
  // Sessions this connection handles by some route other than an `AcpSession` — today only
516
611
  // adoption's replay. Kept beside `ownSessionId` rather than folded into it because they mean
517
612
  // different things: one is "the session we drive", these are "sessions someone else on this
518
613
  // connection is already reading".
519
614
  const knownSessionIds = new Set();
615
+ // p3-fix7 N9: updates diverted before any session is bound. An agent may push its first `session/update` (Claude's
616
+ // `available_commands_update`) before this client has processed the `session/new` response that names the session,
617
+ // so it reads as a stranger's. Dropping it lost the command table of every first probe (run 4 and run 5 daemon
618
+ // logs); kept here, bounded, and replayed to the sink once the session binds.
619
+ const preBind = [];
520
620
  const stream = ndJsonStream(Writable.toWeb(child.stdin),
521
621
  // The child's stdout, minus the notifications the pinned SDK cannot deliver. Everything else
522
622
  // reaches it byte-for-byte; see `draftInterceptor.ts` for the measurements behind this.
@@ -525,19 +625,66 @@ export async function connectAgent(spec, env, agentEnv, options = {}) {
525
625
  // not been told about as our own would send its frames to the SDK, which drops them silently.
526
626
  // Diverting is the recoverable direction — the sink can decide, and it can log.
527
627
  isOwnSession: (sessionId) => (ownSessionId !== null && sessionId === ownSessionId) || knownSessionIds.has(sessionId),
628
+ captureUpdate: (notification) => updateCapture?.(notification) ?? false,
528
629
  onDiverted: (update) => {
529
630
  if (draftSink) {
530
631
  draftSink(update);
531
632
  return;
532
633
  }
533
- // No sink yet: say so once per update rather than dropping in silence. This is reachable
534
- // only before the session is bound, which is before any prompt, so it should stay rare —
535
- // and if it stops being rare, that is worth seeing.
536
- console.warn(`[acp] dropped a ${update.reason} session/update for '${update.sessionId}': ` +
537
- 'no session is bound to this connection yet');
634
+ // No sink yet: keep it until the session binds (bounded), then replay what is ours.
635
+ if (preBind.length >= PRE_BIND_MAX_UPDATES) {
636
+ const dropped = preBind.shift();
637
+ warnDroppedPreBind(dropped.update, 'too many updates arrived before the session was bound');
638
+ }
639
+ preBind.push({ update, at: Date.now() });
538
640
  },
539
641
  }));
540
642
  const app = client({ name: 'axom' });
643
+ app.onNotification('_axom/qoder/profile', (params) => (params ?? {}), (ctx) => {
644
+ const params = ctx.params;
645
+ const profile = parseQoderProfile(params.profile);
646
+ if (!diagnostics.profile || !profile || typeof params.sessionId !== 'string' ||
647
+ profile.rootSessionId !== params.sessionId || !Number.isSafeInteger(params.revision) ||
648
+ params.revision <= 0 || !sameProfileIdentity(diagnostics.profile, profile))
649
+ return;
650
+ if (ownSessionId) {
651
+ applyProfile(params.sessionId, params.revision, profile);
652
+ }
653
+ else {
654
+ // ACP may notify before its new/load reply. Retain only bounded typed candidates;
655
+ // no candidate becomes evidence until the response binds this connection's exact root.
656
+ const previous = pendingProfiles.get(params.sessionId);
657
+ if (previous && (params.revision <= previous.revision ||
658
+ profile.runtimeGeneration !== previous.profile.runtimeGeneration))
659
+ return;
660
+ if (!previous && pendingProfiles.size >= 8)
661
+ return;
662
+ pendingProfiles.set(params.sessionId, { revision: params.revision, profile });
663
+ }
664
+ });
665
+ app.onNotification('_qoder/task_event', (params) => params, (ctx) => {
666
+ const event = parseTaskObservation(ctx.params);
667
+ if (!event || !taskEventMatchesHost(event, {
668
+ selected: options.providerTaskAdapterSelected === true, available: diagnostics.available,
669
+ confirmed: diagnostics.profile?.rootConfirmed === true, rootSessionId: ownSessionId,
670
+ runtimeGeneration: diagnostics.profile?.runtimeGeneration,
671
+ }))
672
+ return;
673
+ for (const listener of providerTaskListeners)
674
+ listener(event);
675
+ });
676
+ // claude-agent-acp's raw-SDK-message channel (design §5.1/§2.1): registered unconditionally on
677
+ // every connection, before `options.configure?.(app)` and before `initialize` — `_claude/sdkMessage`
678
+ // is not a built-in ACP method, so `ClientApp.onNotification` needs the custom-method overload (a
679
+ // parser plus a handler) rather than the typed-literal one `registerPermissionHandlers` uses. The
680
+ // method name is vendor-specific, so this handler simply never fires on a connection to any other
681
+ // agent — there is no need to gate the registration on `agentInfo`, which in any case is not yet
682
+ // known this early (`initialize` has not happened).
683
+ app.onNotification('_claude/sdkMessage', (params) => (params ?? {}), (ctx) => {
684
+ const raw = claudeActivityRaw(ctx.params);
685
+ if (raw)
686
+ activitySink?.(raw);
687
+ });
541
688
  options.configure?.(app);
542
689
  const connection = app.connect(stream);
543
690
  const timeoutMs = options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS;
@@ -595,7 +742,14 @@ export async function connectAgent(spec, env, agentEnv, options = {}) {
595
742
  // `clientCapabilities?.elicitation?.url != null`. Without declaring it, Codex never offers
596
743
  // that method and `elicitation/create` with `mode: 'url'` never arrives to answer (see
597
744
  // `permission.ts`'s `handleCreateElicitation`).
598
- elicitation: { url: {} },
745
+ //
746
+ // `form` (`ElicitationFormCapabilities`, `types.gen.d.ts:4455`) is what keeps Claude's
747
+ // AskUserQuestion alive: without it the adapter puts the tool in `disallowedTools`
748
+ // (`claude-agent-acp/dist/acp-agent.js:5959`), so the model never has it at all — the
749
+ // symptom is prose questions, not an unrendered card. This is the trigger for the whole
750
+ // elicitation-standardization plan: everything through Task 17 built the machinery to
751
+ // answer `elicitation/create` once it arrives; this line is what makes it arrive at all.
752
+ elicitation: { url: {}, form: {} },
599
753
  _meta: {
600
754
  'subagent-transcript': true,
601
755
  ...airCapabilityMeta(AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY, AIR_ASYNC_TASKS_CAPABILITY),
@@ -630,12 +784,14 @@ export async function connectAgent(spec, env, agentEnv, options = {}) {
630
784
  throw error;
631
785
  }
632
786
  const sessionCapabilities = initResponse.agentCapabilities?.sessionCapabilities;
787
+ diagnostics = { ...diagnostics, profile: parseQoderProfile(initResponse._meta?.qoderProfile) };
633
788
  const capabilities = {
634
789
  // Capability markers are empty objects (`{ _meta? }`); supplying `{}` means "supported", and
635
790
  // omitting the field (or explicit `null`) means "not supported". A truthiness check reads `{}`
636
791
  // correctly by accident but reads `null`/`undefined` correctly too, which hides the real bug:
637
792
  // this must stay an explicit `!= null`, never `Boolean(...)` or a bare truthy test.
638
793
  list: sessionCapabilities?.list != null,
794
+ delete: sessionCapabilities?.delete != null,
639
795
  resume: sessionCapabilities?.resume != null,
640
796
  loadSession: initResponse.agentCapabilities?.loadSession === true,
641
797
  logout: initResponse.agentCapabilities?.auth?.logout != null,
@@ -649,6 +805,7 @@ export async function connectAgent(spec, env, agentEnv, options = {}) {
649
805
  * is a field nobody can act on.
650
806
  */
651
807
  promptImage: initResponse.agentCapabilities?.promptCapabilities?.image === true,
808
+ mcpHttp: initResponse.agentCapabilities?.mcpCapabilities?.http === true,
652
809
  };
653
810
  // Honour the advertised capability, not what the agent happens to route: some adapters (e.g.
654
811
  // codex-acp routing `session/fork`) implement more than they declare in `agentCapabilities`.
@@ -658,6 +815,12 @@ export async function connectAgent(spec, env, agentEnv, options = {}) {
658
815
  const authMethods = advertisedAuthMethods
659
816
  .map(toAuthMethodSnapshot)
660
817
  .filter((method) => method !== null);
818
+ const terminalLogins = new Map();
819
+ for (const method of advertisedAuthMethods) {
820
+ const spec = terminalLoginSpecOf(method);
821
+ if (spec && typeof method.id === 'string')
822
+ terminalLogins.set(method.id, spec);
823
+ }
661
824
  if (authMethods.length !== advertisedAuthMethods.length) {
662
825
  // Named and counted rather than dropped in silence: the user loses a sign-in method they can
663
826
  // see advertised in the agent's own docs, and the only honest answer to "why" is which agent
@@ -669,16 +832,60 @@ export async function connectAgent(spec, env, agentEnv, options = {}) {
669
832
  'id/name; ignoring them');
670
833
  }
671
834
  return {
835
+ get runtimeDiagnostics() { return diagnostics; },
836
+ providerTaskAdapterSelected: options.providerTaskAdapterSelected === true,
837
+ publishCallbacks(snapshot) {
838
+ if (snapshot.rootSessionId !== ownSessionId)
839
+ return;
840
+ diagnostics = { ...diagnostics, revision: diagnostics.revision + 1, callbacks: snapshot };
841
+ for (const listener of diagnosticListeners)
842
+ listener(diagnostics);
843
+ },
844
+ onRuntimeDiagnostics(cb) { diagnosticListeners.add(cb); },
845
+ onProviderTaskEvent(cb) { providerTaskListeners.add(cb); },
672
846
  capabilities,
673
847
  authMethods,
848
+ terminalLogins,
674
849
  supportsSteering: steeringAdvertised(initResponse._meta),
675
850
  steeringTimings: steeringTimings(initResponse._meta),
676
851
  agentInfo: initResponse.agentInfo ?? undefined,
677
852
  connection,
853
+ redactStderrSecret: (secret) => stderrTail.redactSecret(secret),
678
854
  agentProcessAlive: () => child.exitCode === null && child.signalCode === null,
855
+ captureSessionUpdates(sink) {
856
+ if (updateCapture)
857
+ throw new Error('A session replay is already active on this connection');
858
+ updateCapture = sink;
859
+ return () => { if (updateCapture === sink)
860
+ updateCapture = null; };
861
+ },
679
862
  bindDraftUpdates(sessionId, sink) {
680
863
  ownSessionId = sessionId;
681
864
  draftSink = sink;
865
+ const current = diagnostics.profile;
866
+ if (current?.rootSessionId && current.rootSessionId !== sessionId) {
867
+ diagnostics = { ...diagnostics, profile: { ...current, rootSessionId: undefined,
868
+ runtimeGeneration: undefined, rootConfirmed: false, initialized: false, observerHealthy: false } };
869
+ }
870
+ const pending = pendingProfiles.get(sessionId);
871
+ pendingProfiles.clear();
872
+ if (pending)
873
+ applyProfile(sessionId, pending.revision, pending.profile);
874
+ // p3-fix7 N9: replay what arrived before this moment. Our own session's updates go to the sink as `pre-bind`
875
+ // (they are ours, not a child's); anything else, or anything older than the window, is dropped with the
876
+ // warning it always had.
877
+ const now = Date.now();
878
+ for (const { update, at } of preBind.splice(0)) {
879
+ if (update.sessionId === sessionId && now - at <= PRE_BIND_MAX_AGE_MS) {
880
+ sink(update.reason === 'foreign-session' ? { ...update, reason: 'pre-bind' } : update);
881
+ }
882
+ else {
883
+ warnDroppedPreBind(update, 'no session is bound to this connection yet');
884
+ }
885
+ }
886
+ },
887
+ bindActivity(sink) {
888
+ activitySink = sink;
682
889
  },
683
890
  markSessionKnown(sessionId) {
684
891
  knownSessionIds.add(sessionId);
@@ -689,3 +896,10 @@ export async function connectAgent(spec, env, agentEnv, options = {}) {
689
896
  close: () => closeAgentTransport(child, connection),
690
897
  };
691
898
  }
899
+ /** p3-fix7 N9: how many updates, and for how long, a connection keeps before its session is bound. */
900
+ const PRE_BIND_MAX_UPDATES = 32;
901
+ const PRE_BIND_MAX_AGE_MS = 5_000;
902
+ /** Says so once per update rather than dropping in silence; it should stay rare, and if it stops being rare it is worth seeing. */
903
+ function warnDroppedPreBind(update, why) {
904
+ console.warn(`[acp] dropped a ${update.reason} session/update for '${update.sessionId}': ${why}`);
905
+ }