@myagentroam/node 0.1.2 → 0.1.3

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.
@@ -216,7 +216,7 @@ function claudePermissionMode(access) {
216
216
  function channelMcpServer(input) {
217
217
  return createSdkMcpServer({
218
218
  name: 'myagentroam',
219
- version: '0.1.2',
219
+ version: '0.1.3',
220
220
  instructions: 'Use the myagentroam_channel_reply tool only to send a concise reply to the Admin Channel.',
221
221
  alwaysLoad: true,
222
222
  tools: [
@@ -98,7 +98,7 @@ export class CodexAppServerClient {
98
98
  this.failAll(new Error('CODEX_APP_SERVER_EXITED'));
99
99
  });
100
100
  await this.request('initialize', {
101
- clientInfo: { name: 'myagentroam', title: 'MyAgentRoam', version: '0.1.2' },
101
+ clientInfo: { name: 'myagentroam', title: 'MyAgentRoam', version: '0.1.3' },
102
102
  capabilities: { experimentalApi: true }
103
103
  });
104
104
  this.notify('initialized', {});
@@ -98,6 +98,7 @@ export declare class NodeConnector {
98
98
  /** Bounded Runner diagnostic paired with a fixed external resume reason code. */
99
99
  private readonly externalResumeFailureDetails;
100
100
  private readonly claudeApprovals;
101
+ private readonly claudeUserInputs;
101
102
  private readonly commandStates;
102
103
  private readonly runnerUsageReader;
103
104
  private codexFastEnabled;
@@ -262,6 +263,7 @@ export declare class NodeConnector {
262
263
  private deliverClaudeChannel;
263
264
  private cancelCodexTurn;
264
265
  private respondCodexUserInput;
266
+ private respondUserInput;
265
267
  /** Returns true when cancellation already made this Run terminal. */
266
268
  private completeCancelledRun;
267
269
  private presentSession;
@@ -315,6 +317,7 @@ export declare class NodeConnector {
315
317
  private workbenchManifest;
316
318
  private resumeDelivery;
317
319
  private requestClaudeApproval;
320
+ private requestClaudeUserInput;
318
321
  /**
319
322
  * An approval is an individual Runner callback. Keep its exact payload in
320
323
  * the process-local snapshot and emit a Run event so an already open
package/dist/connector.js CHANGED
@@ -9,7 +9,7 @@ import { loadNodeConfig, nodeDatabasePath, saveNodeConfig } from './config.js';
9
9
  import { NodeMetrics, nodeLog } from './operational.js';
10
10
  import { assertLocalSqlitePath } from './storage.js';
11
11
  import { detectCapabilitiesAsync, unavailableCapabilities } from './capabilities.js';
12
- import { inspectWorkspace, isWorkspaceChangeVisible, listWorkspaceDirectories, listWorkspaceFiles, preflightWorkspaceUpload, readCurrentChangeDiff, readCurrentChangesSummary, readGitSummary, readRestrictedDiff, readAllowedTextFile, readWorkspaceTextFile, restoreCurrentChange, WorkspaceFileIndex, workspaceUploadTemporaryName } from './workspace.js';
12
+ import { inspectWorkspace, isWorkspaceChangeVisible, listWorkspaceDirectories, listWorkspaceFiles, preflightWorkspaceUpload, listInitializedSubmodulePaths, readCurrentChangeDiff, readCurrentChangesSummary, readGitSummary, readRestrictedDiff, readAllowedTextFile, readWorkspaceTextFile, resolveGitRepository, restoreCurrentChange, WorkspaceFileIndex, workspaceUploadTemporaryName } from './workspace.js';
13
13
  import { ReliableWorkbenchEventBuffer } from './event-buffer.js';
14
14
  import { CodexAppServerClient, codexSessionControl } from './codex-app-server.js';
15
15
  import { ClaudeAgentSdkAdapter, denyPermission } from './claude-agent-sdk.js';
@@ -19,7 +19,7 @@ import { TerminalManager } from './terminal.js';
19
19
  import { discoverAllNativeSessions, discoverNativeSessions, readCodexNativeContextUsage, readCodexNativeTaskActivity, readNativeSession, removeNativeSession } from './native-session-history.js';
20
20
  import { declaredRunnerProfiles, supportsRunnerConfiguration } from './runner-profiles.js';
21
21
  import { NodeRuntimeState } from './runtime-state.js';
22
- import { CommandStateStore, parseCommandInvocation } from './runner-command-engine.js';
22
+ import { CommandStateStore, parseCommandInvocation, withClaudePlanTag, stripClaudePlanTag } from './runner-command-engine.js';
23
23
  import { RunnerUsageReader } from './runner-usage.js';
24
24
  const HEARTBEAT_MS = 15_000;
25
25
  const WORKSPACE_FILE_INDEX_CACHE_LIMIT = 8;
@@ -31,6 +31,7 @@ const CAPABILITY_REFRESH_MS = 60_000;
31
31
  const SESSION_WATCH_INTERVAL_MS = 2_000;
32
32
  const SESSION_WATCH_TTL_MS = 45_000;
33
33
  const SESSION_WATCH_MAX_FAILURES = 3;
34
+ const CLAUDE_CONTEXT_CACHE_MS = 30_000;
34
35
  const SESSION_WATCH_INITIAL_TURN_LIMIT = 30;
35
36
  const RETRY_MIN_MS = 500;
36
37
  const RETRY_MAX_MS = 30_000;
@@ -159,6 +160,7 @@ export class NodeConnector {
159
160
  /** Bounded Runner diagnostic paired with a fixed external resume reason code. */
160
161
  externalResumeFailureDetails = new Map();
161
162
  claudeApprovals = new Map();
163
+ claudeUserInputs = new Map();
162
164
  commandStates = new CommandStateStore();
163
165
  runnerUsageReader;
164
166
  codexFastEnabled = false;
@@ -235,6 +237,11 @@ export class NodeConnector {
235
237
  approval.resolve('REJECTED');
236
238
  }
237
239
  this.claudeApprovals.clear();
240
+ for (const pending of this.claudeUserInputs.values()) {
241
+ clearTimeout(pending.timer);
242
+ pending.reject('NODE_STOPPED');
243
+ }
244
+ this.claudeUserInputs.clear();
238
245
  for (const timer of this.queuePauseWindows.values())
239
246
  clearTimeout(timer);
240
247
  this.queuePauseWindows.clear();
@@ -1081,16 +1088,20 @@ export class NodeConnector {
1081
1088
  return this.currentChangesForWorkspace(workspace);
1082
1089
  }
1083
1090
  if (operation === 'workspace.change.diff') {
1084
- if (typeof input.workspaceId !== 'string' || typeof input.path !== 'string')
1091
+ if (typeof input.workspaceId !== 'string' ||
1092
+ typeof input.path !== 'string' ||
1093
+ (input.repositoryPath !== undefined && typeof input.repositoryPath !== 'string'))
1085
1094
  throw new Error('GIT_DIFF_INVALID');
1086
1095
  const workspace = this.requireWorkspace(input.workspaceId);
1087
1096
  if (workspace.kind !== 'GIT_WORKSPACE')
1088
1097
  throw new Error('WORKSPACE_NOT_GIT');
1089
- return { diff: await readCurrentChangeDiff(workspace.path, input.path) };
1098
+ const repository = await resolveGitRepository(workspace.path, input.repositoryPath ?? '');
1099
+ return { diff: await readCurrentChangeDiff(repository, input.path) };
1090
1100
  }
1091
1101
  if (operation === 'workspace.change.restore') {
1092
1102
  if (typeof input.workspaceId !== 'string' ||
1093
1103
  typeof input.path !== 'string' ||
1104
+ (input.repositoryPath !== undefined && typeof input.repositoryPath !== 'string') ||
1094
1105
  (input.state !== 'STAGED' && input.state !== 'UNSTAGED' && input.state !== 'UNTRACKED'))
1095
1106
  throw new Error('GIT_CHANGE_RESTORE_UNAVAILABLE');
1096
1107
  const workspace = this.requireWorkspace(input.workspaceId);
@@ -1098,7 +1109,8 @@ export class NodeConnector {
1098
1109
  throw new Error('WORKSPACE_NOT_GIT');
1099
1110
  if (this.runtime.hasActiveWorkspaceLease(workspace.id))
1100
1111
  throw new Error('WORKSPACE_BUSY');
1101
- await restoreCurrentChange(workspace.path, input.path, input.state);
1112
+ const repository = await resolveGitRepository(workspace.path, input.repositoryPath ?? '');
1113
+ await restoreCurrentChange(repository, input.path, input.state);
1102
1114
  // A read started before the write must not satisfy the explicit post-write refresh.
1103
1115
  this.invalidateWorkspaceWatchTopics(workspace.id);
1104
1116
  return { restored: true };
@@ -1563,12 +1575,16 @@ export class NodeConnector {
1563
1575
  const runnerInput = withNonImageAttachmentPrompt(input.content, attachmentPaths);
1564
1576
  this.runtime.updateQueuedMessage(created.run.id, runnerInput);
1565
1577
  const commandStates = this.commandStates.list(session.id);
1578
+ const planActive = commandStates.some((state) => state.commandId === 'plan');
1566
1579
  this.queuedRunStarts.set(created.run.id, {
1567
1580
  runId: created.run.id,
1568
1581
  sessionId: created.run.sessionId,
1569
1582
  workspaceId: created.run.workspaceId,
1570
1583
  runner: created.run.runner,
1571
- input: runnerInput,
1584
+ // Claude 标签式 Plan Mode 只包装发往 Runner 的输入,用户气泡保持原文。
1585
+ input: created.run.runner === 'claude-code' && planActive
1586
+ ? withClaudePlanTag(runnerInput)
1587
+ : runnerInput,
1572
1588
  attachments: runnerImageAttachments,
1573
1589
  attachmentPaths,
1574
1590
  cwd: session.cwd,
@@ -1577,9 +1593,7 @@ export class NodeConnector {
1577
1593
  effort: session.effort,
1578
1594
  // Plan 只切换 Codex 协作模式,不得覆盖会话已选权限。
1579
1595
  access: session.access,
1580
- collaborationMode: commandStates.some((state) => state.commandId === 'plan')
1581
- ? 'plan'
1582
- : 'default',
1596
+ collaborationMode: planActive ? 'plan' : 'default',
1583
1597
  ...(this.codexFastEnabled ? { serviceTier: 'fast' } : {})
1584
1598
  });
1585
1599
  this.runtime.resumeSessionQueue(session.id);
@@ -1651,7 +1665,12 @@ export class NodeConnector {
1651
1665
  const runnerInput = withNonImageAttachmentPrompt(input.content.trim(), pending?.attachmentPaths ?? []);
1652
1666
  this.runtime.updateQueuedMessage(input.runId, runnerInput);
1653
1667
  if (pending !== undefined)
1654
- this.queuedRunStarts.set(input.runId, { ...pending, input: runnerInput });
1668
+ this.queuedRunStarts.set(input.runId, {
1669
+ ...pending,
1670
+ input: pending.runner === 'claude-code' && pending.collaborationMode === 'plan'
1671
+ ? withClaudePlanTag(runnerInput)
1672
+ : runnerInput
1673
+ });
1655
1674
  this.emitWorkspaceQueue(run.workspaceId, run.sessionId);
1656
1675
  return { queue: this.presentWorkspaceQueue(run.workspaceId, run.sessionId) };
1657
1676
  }
@@ -1750,7 +1769,7 @@ export class NodeConnector {
1750
1769
  typeof input.requestId !== 'string' ||
1751
1770
  !isPlainRecord(input.answers))
1752
1771
  throw new Error('USER_INPUT_INVALID');
1753
- this.respondCodexUserInput(input.runId, input.requestId, input.answers);
1772
+ this.respondUserInput(input.runId, input.requestId, input.answers);
1754
1773
  return { accepted: true };
1755
1774
  }
1756
1775
  if (operation === 'session.interrupt') {
@@ -2088,8 +2107,6 @@ export class NodeConnector {
2088
2107
  typeof input.overwrite !== 'boolean' ||
2089
2108
  (composerAttachment !== undefined && input.directory !== undefined))
2090
2109
  throw new Error('UPLOAD_INVALID');
2091
- if (composerAttachment === undefined && this.runtime.hasActiveWorkspaceLease(workspace.id))
2092
- throw new Error('WORKSPACE_BUSY');
2093
2110
  if (composerAttachment !== undefined) {
2094
2111
  if (!validComposerAttachmentName(input.name) ||
2095
2112
  input.size > MAX_COMPOSER_ATTACHMENT_BYTES ||
@@ -2300,24 +2317,56 @@ export class NodeConnector {
2300
2317
  return existing;
2301
2318
  const version = this.workspaceChangesVersions.get(workspace.id) ?? 0;
2302
2319
  const request = this.workspaceChangesLimiter.run(async () => {
2303
- const summary = await readCurrentChangesSummary(workspace.path);
2304
- const visibleChanges = (await Promise.all(summary.changes.map(async (change) => ({
2305
- change,
2306
- visible: await isWorkspaceChangeVisible(workspace.path, change.path)
2307
- }))))
2308
- .filter(({ visible }) => visible)
2309
- .map(({ change }) => change);
2320
+ const repositoryPaths = ['', ...(await listInitializedSubmodulePaths(workspace.path))];
2321
+ const readRepository = async (repositoryPath) => {
2322
+ const repository = repositoryPath === ''
2323
+ ? workspace.path
2324
+ : await resolveGitRepository(workspace.path, repositoryPath);
2325
+ const summary = await readCurrentChangesSummary(repository);
2326
+ const visibleChanges = (await Promise.all(summary.changes.map(async (change) => ({
2327
+ change,
2328
+ visible: await isWorkspaceChangeVisible(repository, change.path)
2329
+ }))))
2330
+ .filter(({ visible }) => visible)
2331
+ .map(({ change }) => change);
2332
+ return {
2333
+ repositoryPath,
2334
+ repositoryName: repositoryPath === '' ? '根仓库' : repositoryPath,
2335
+ branch: summary.branch,
2336
+ changes: visibleChanges
2337
+ };
2338
+ };
2339
+ const root = await readRepository('');
2340
+ const submoduleRepositories = (await Promise.all(repositoryPaths.slice(1).map(async (repositoryPath) => {
2341
+ try {
2342
+ return await readRepository(repositoryPath);
2343
+ }
2344
+ catch {
2345
+ return undefined;
2346
+ }
2347
+ }))).filter((repository) => repository !== undefined);
2348
+ const repositories = [root, ...submoduleRepositories];
2310
2349
  const value = {
2311
- branch: summary.branch,
2312
- changes: visibleChanges.map((change) => ({
2350
+ branch: root.branch,
2351
+ changes: root.changes.map((change) => ({
2313
2352
  ...change,
2314
2353
  additions: null,
2315
2354
  deletions: null,
2316
- // Current status does not read file bodies. Detailed binary/diff data
2317
- // remains an explicit on-demand Git request rather than a background
2318
- // scan of the Workspace.
2319
2355
  binary: false,
2320
2356
  diffAvailable: change.state !== 'UNTRACKED'
2357
+ })),
2358
+ repositories: repositories.map((repository) => ({
2359
+ ...repository,
2360
+ changes: repository.changes.map((change) => ({
2361
+ ...change,
2362
+ additions: null,
2363
+ deletions: null,
2364
+ // Current status does not read file bodies. Detailed binary/diff data
2365
+ // remains an explicit on-demand Git request rather than a background
2366
+ // scan of the Workspace.
2367
+ binary: false,
2368
+ diffAvailable: change.state !== 'UNTRACKED'
2369
+ }))
2321
2370
  }))
2322
2371
  };
2323
2372
  if ((this.workspaceChangesVersions.get(workspace.id) ?? 0) === version)
@@ -3313,6 +3362,8 @@ export class NodeConnector {
3313
3362
  this.emitRunEvent(runId, 'run.rejected', { code: 'EXTERNAL_THREAD_OBSERVE_ONLY' }, 'FAILED');
3314
3363
  return;
3315
3364
  }
3365
+ if (typeof payload.externalSessionId === 'string')
3366
+ this.completedClaudeContextReads.delete(`claude:${cwd}:${payload.externalSessionId}`);
3316
3367
  this.activeRuns.add(runId);
3317
3368
  void (async () => {
3318
3369
  try {
@@ -3452,7 +3503,10 @@ export class NodeConnector {
3452
3503
  dataBase64: attachment.dataBase64
3453
3504
  }))
3454
3505
  }),
3455
- onPermission: (toolName, toolInput) => this.requestClaudeApproval(runId, toolName, toolInput),
3506
+ onPermission: (toolName, toolInput, options) => toolName === 'AskUserQuestion'
3507
+ ? (toolKinds.set(options.toolUseID, 'user_input_request'),
3508
+ this.requestClaudeUserInput(runId, toolInput, options.toolUseID))
3509
+ : this.requestClaudeApproval(runId, toolName, toolInput),
3456
3510
  onChannelReply: (content) => this.emitRunEvent(runId, 'channel.reply', { content }),
3457
3511
  onMessage: (message) => {
3458
3512
  const record = message;
@@ -3475,7 +3529,15 @@ export class NodeConnector {
3475
3529
  }
3476
3530
  if (record.type === 'assistant') {
3477
3531
  const text = extractClaudeText(record);
3478
- if (text.length > 0)
3532
+ const planText = claudePlanText(text);
3533
+ if (planText !== undefined)
3534
+ this.emitConversationItem(runId, {
3535
+ itemId: boundedConversationItemId('claude-plan', typeof record.uuid === 'string' ? record.uuid : `${runId}:plan`),
3536
+ kind: 'plan',
3537
+ status: 'COMPLETED',
3538
+ payload: { explanation: compactRunnerText(planText, 20_000), steps: [] }
3539
+ });
3540
+ else if (text.length > 0)
3479
3541
  this.emitRunEvent(runId, 'text.delta', { text });
3480
3542
  for (const [index, text] of extractClaudeThinkingSummaries(record).entries()) {
3481
3543
  this.emitConversationItem(runId, {
@@ -3486,6 +3548,10 @@ export class NodeConnector {
3486
3548
  });
3487
3549
  }
3488
3550
  for (const tool of extractClaudeToolUses(record)) {
3551
+ if (tool.name === 'AskUserQuestion') {
3552
+ toolKinds.set(tool.id, 'user_input_request');
3553
+ continue;
3554
+ }
3489
3555
  const itemId = boundedConversationItemId('claude-tool', tool.id);
3490
3556
  const kind = isClaudeCommandTool(tool.name) ? 'command_execution' : 'tool_call';
3491
3557
  toolKinds.set(tool.id, kind);
@@ -3536,6 +3602,8 @@ export class NodeConnector {
3536
3602
  if (record.type === 'user') {
3537
3603
  for (const result of extractClaudeToolResults(record)) {
3538
3604
  const kind = toolKinds.get(result.toolUseId) ?? 'tool_call';
3605
+ if (kind === 'user_input_request')
3606
+ continue;
3539
3607
  this.emitConversationItem(runId, {
3540
3608
  itemId: boundedConversationItemId('claude-tool', result.toolUseId),
3541
3609
  kind,
@@ -3684,6 +3752,41 @@ export class NodeConnector {
3684
3752
  merge: true
3685
3753
  });
3686
3754
  }
3755
+ respondUserInput(runId, requestId, rawAnswers) {
3756
+ if (this.codexUserInputs.has(requestId)) {
3757
+ this.respondCodexUserInput(runId, requestId, rawAnswers);
3758
+ return;
3759
+ }
3760
+ const pending = this.claudeUserInputs.get(requestId);
3761
+ if (pending === undefined || pending.runId !== runId)
3762
+ throw new Error('USER_INPUT_NOT_FOUND');
3763
+ const answers = {};
3764
+ for (const question of pending.questions) {
3765
+ const values = rawAnswers[question.id];
3766
+ if (!Array.isArray(values) || values.length === 0 || values.length > 8)
3767
+ throw new Error('USER_INPUT_INVALID');
3768
+ const normalized = values.map((value) => {
3769
+ if (typeof value !== 'string' || value.length === 0 || value.length > 500)
3770
+ throw new Error('USER_INPUT_INVALID');
3771
+ if (!question.allowOther && !question.options.some((option) => option.value === value))
3772
+ throw new Error('USER_INPUT_INVALID');
3773
+ return value;
3774
+ });
3775
+ if (!question.multiSelect && normalized.length !== 1)
3776
+ throw new Error('USER_INPUT_INVALID');
3777
+ answers[question.prompt] = normalized.join(', ');
3778
+ }
3779
+ clearTimeout(pending.timer);
3780
+ this.claudeUserInputs.delete(requestId);
3781
+ pending.resolve(answers);
3782
+ this.emitConversationItem(runId, {
3783
+ itemId: boundedConversationItemId('claude-input', requestId),
3784
+ kind: 'user_input_request',
3785
+ status: 'COMPLETED',
3786
+ payload: { requestId, questions: pending.questions },
3787
+ merge: true
3788
+ });
3789
+ }
3687
3790
  /** Returns true when cancellation already made this Run terminal. */
3688
3791
  completeCancelledRun(runId, cwd) {
3689
3792
  const run = this.runtime.getRun(runId);
@@ -3980,7 +4083,10 @@ export class NodeConnector {
3980
4083
  // Context detail is optional Runner metadata. Cache the miss briefly to
3981
4084
  // avoid spawning one Claude process per browser polling tick.
3982
4085
  }
3983
- this.completedClaudeContextReads.set(key, { context, expiresAt: Date.now() + 5_000 });
4086
+ this.completedClaudeContextReads.set(key, {
4087
+ context,
4088
+ expiresAt: Date.now() + CLAUDE_CONTEXT_CACHE_MS
4089
+ });
3984
4090
  this.emitWorkbenchEvent('session', {
3985
4091
  sessionId: session.id,
3986
4092
  workspaceId: session.workspaceId,
@@ -3991,9 +4097,27 @@ export class NodeConnector {
3991
4097
  /** Executes only Node-declared interactions; the browser never supplies behavior. */
3992
4098
  async executeRunnerCommand(session, command, input) {
3993
4099
  if (session.runner === 'claude-code') {
3994
- if (command.id !== 'compact')
4100
+ if (command.id === 'compact')
4101
+ return this.startClaudeCompaction(session, input);
4102
+ if (command.id !== 'plan')
3995
4103
  throw new Error('RUNNER_COMMAND_UNAVAILABLE');
3996
- return this.startClaudeCompaction(session, input);
4104
+ // 标签式 Plan Mode 与 SDK 原生 plan 权限不叠加。
4105
+ if (session.access === 'plan')
4106
+ throw new Error('RUNNER_COMMAND_UNAVAILABLE');
4107
+ const current = this.commandStates.get(session.id, 'plan');
4108
+ if (current?.active)
4109
+ this.commandStates.clear(session.id, 'plan');
4110
+ else
4111
+ this.commandStates.set(session.id, {
4112
+ commandId: 'plan',
4113
+ active: true,
4114
+ label: 'Plan Mode',
4115
+ detail: '后续消息将以标签式规划模式运行,只读规划并输出计划卡。',
4116
+ closable: true,
4117
+ closeInput: '/plan',
4118
+ continuation: { label: 'Implement', prompt: '请根据上述计划开始实施。' }
4119
+ });
4120
+ return { command: command.id, states: this.commandStates.list(session.id) };
3997
4121
  }
3998
4122
  if (command.id === 'plan') {
3999
4123
  await this.codexClient.start();
@@ -4212,6 +4336,45 @@ export class NodeConnector {
4212
4336
  });
4213
4337
  });
4214
4338
  }
4339
+ requestClaudeUserInput(runId, toolInput, toolUseId) {
4340
+ const questions = claudeUserInputQuestions(toolInput);
4341
+ if (questions.length === 0)
4342
+ return Promise.resolve(denyPermission('USER_INPUT_INVALID'));
4343
+ const requestId = `claude:${runId}:${toolUseId}`;
4344
+ return new Promise((resolve) => {
4345
+ const input = { ...toolInput };
4346
+ const settleDeny = (message = 'USER_INPUT_EXPIRED') => resolve(denyPermission(message));
4347
+ const timer = setTimeout(() => {
4348
+ const pending = this.claudeUserInputs.get(requestId);
4349
+ if (pending === undefined)
4350
+ return;
4351
+ this.claudeUserInputs.delete(requestId);
4352
+ pending.reject('USER_INPUT_EXPIRED');
4353
+ this.emitConversationItem(runId, {
4354
+ itemId: boundedConversationItemId('claude-input', requestId),
4355
+ kind: 'user_input_request',
4356
+ status: 'FAILED',
4357
+ payload: { requestId, questions },
4358
+ merge: true
4359
+ });
4360
+ }, this.claudeApprovalTimeoutMs);
4361
+ this.claudeUserInputs.set(requestId, {
4362
+ runId,
4363
+ requestId,
4364
+ questions,
4365
+ input,
4366
+ resolve: (answers) => resolve({ behavior: 'allow', updatedInput: { ...input, answers } }),
4367
+ reject: settleDeny,
4368
+ timer
4369
+ });
4370
+ this.emitConversationItem(runId, {
4371
+ itemId: boundedConversationItemId('claude-input', requestId),
4372
+ kind: 'user_input_request',
4373
+ status: 'PENDING',
4374
+ payload: { requestId, questions }
4375
+ });
4376
+ });
4377
+ }
4215
4378
  /**
4216
4379
  * An approval is an individual Runner callback. Keep its exact payload in
4217
4380
  * the process-local snapshot and emit a Run event so an already open
@@ -5045,9 +5208,24 @@ function nativeConversationPage(session, history, input, runtimeTurns = []) {
5045
5208
  const turnId = `${session.id}:native:${index}`;
5046
5209
  const isToolCall = entry.kind === 'tool_call';
5047
5210
  const isCommand = isToolCall && isClaudeCommandTool(entry.toolName);
5211
+ const userInputQuestions = session.runner === 'claude-code' &&
5212
+ isToolCall &&
5213
+ entry.toolName === 'AskUserQuestion' &&
5214
+ isPlainRecord(entry.input)
5215
+ ? claudeUserInputQuestions(entry.input)
5216
+ : [];
5217
+ const isUserInputRequest = userInputQuestions.length > 0;
5048
5218
  const clientMessageId = !isToolCall && entry.role === 'USER'
5049
5219
  ? nativeUserClientMessageId(entry, time, runtimeTurns)
5050
5220
  : null;
5221
+ // Claude 标签式 Plan Mode:用户正文剥离注入的规划指令标签,
5222
+ // 完整的 <proposed_plan> Agent 文本归一化为计划卡。
5223
+ const entryText = entry.kind === 'message'
5224
+ ? entry.role === 'USER'
5225
+ ? stripClaudePlanTag(entry.text)
5226
+ : entry.text
5227
+ : '';
5228
+ const planText = entry.kind === 'message' && entry.role !== 'USER' ? claudePlanText(entryText) : undefined;
5051
5229
  const item = {
5052
5230
  id: `${turnId}:item`,
5053
5231
  sessionId: session.id,
@@ -5056,38 +5234,49 @@ function nativeConversationPage(session, history, input, runtimeTurns = []) {
5056
5234
  parentItemId: null,
5057
5235
  sourceSequence: index,
5058
5236
  revision: 0,
5059
- kind: isToolCall
5060
- ? isCommand
5061
- ? 'command_execution'
5062
- : 'tool_call'
5063
- : entry.role === 'USER'
5064
- ? 'user_message'
5065
- : 'assistant_message',
5237
+ kind: isUserInputRequest
5238
+ ? 'user_input_request'
5239
+ : isToolCall
5240
+ ? isCommand
5241
+ ? 'command_execution'
5242
+ : 'tool_call'
5243
+ : entry.role === 'USER'
5244
+ ? 'user_message'
5245
+ : planText !== undefined
5246
+ ? 'plan'
5247
+ : 'assistant_message',
5066
5248
  status: 'COMPLETED',
5067
- payload: isToolCall
5068
- ? isCommand
5069
- ? {
5070
- command: nativeClaudeCommand(entry),
5071
- cwd: null,
5072
- outputPreview: entry.outputSummary ?? '',
5073
- outputRef: null,
5074
- exitCode: null,
5075
- durationMs: null,
5076
- truncated: entry.inputTruncated || entry.outputTruncated
5077
- }
5078
- : {
5079
- namespace: session.runner,
5080
- toolName: entry.toolName,
5081
- title: entry.toolName,
5082
- inputSummary: entry.inputSummary,
5083
- outputSummary: entry.outputSummary,
5084
- progressLabel: null,
5085
- errorCode: entry.failed ? 'TOOL_EXECUTION_FAILED' : null,
5086
- truncated: entry.inputTruncated || entry.outputTruncated
5087
- }
5088
- : entry.role === 'USER'
5089
- ? { clientMessageId, text: entry.text, contexts: [], delivery: 'ACCEPTED' }
5090
- : { text: entry.text, format: 'markdown', source: 'native-file' },
5249
+ payload: isUserInputRequest
5250
+ ? {
5251
+ requestId: `native:${entry.kind === 'tool_call' ? entry.toolUseId : `item-${index}`}`,
5252
+ questions: userInputQuestions
5253
+ }
5254
+ : isToolCall
5255
+ ? isCommand
5256
+ ? {
5257
+ command: nativeClaudeCommand(entry),
5258
+ cwd: null,
5259
+ outputPreview: entry.outputSummary ?? '',
5260
+ outputRef: null,
5261
+ exitCode: null,
5262
+ durationMs: null,
5263
+ truncated: entry.inputTruncated || entry.outputTruncated
5264
+ }
5265
+ : {
5266
+ namespace: session.runner,
5267
+ toolName: entry.toolName,
5268
+ title: entry.toolName,
5269
+ inputSummary: entry.inputSummary,
5270
+ outputSummary: entry.outputSummary,
5271
+ progressLabel: null,
5272
+ errorCode: entry.failed ? 'TOOL_EXECUTION_FAILED' : null,
5273
+ truncated: entry.inputTruncated || entry.outputTruncated
5274
+ }
5275
+ : entry.role === 'USER'
5276
+ ? { clientMessageId, text: entryText, contexts: [], delivery: 'ACCEPTED' }
5277
+ : planText !== undefined
5278
+ ? { explanation: compactRunnerText(planText, 20_000), steps: [] }
5279
+ : { text: entryText, format: 'markdown', source: 'native-file' },
5091
5280
  startedAt: time,
5092
5281
  completedAt: time
5093
5282
  };
@@ -5124,7 +5313,8 @@ function nativeUserClientMessageId(entry, createdAt, runtimeTurns) {
5124
5313
  continue;
5125
5314
  const payload = user.payload;
5126
5315
  const clientMessageId = typeof payload.clientMessageId === 'string' ? payload.clientMessageId : null;
5127
- if (clientMessageId === null || payload.text !== entry.text)
5316
+ // 标签式 Plan Mode 的指令标签只存在于原生 transcript,运行态气泡始终是原文。
5317
+ if (clientMessageId === null || payload.text !== stripClaudePlanTag(entry.text))
5128
5318
  continue;
5129
5319
  const runtimeCreatedAt = user.startedAt ?? turn.startedAt;
5130
5320
  if (runtimeCreatedAt !== null &&
@@ -5338,6 +5528,20 @@ function codexPlanText(value) {
5338
5528
  .trim();
5339
5529
  return plan.length > 0 ? plan : undefined;
5340
5530
  }
5531
+ /**
5532
+ * Claude 标签式 Plan Mode 的最终计划以 `<proposed_plan>` 完整包裹输出;
5533
+ * 未闭合或缺失标签的文本保持普通 assistant 消息。
5534
+ */
5535
+ function claudePlanText(text) {
5536
+ const trimmed = text.trim();
5537
+ if (!trimmed.startsWith('<proposed_plan>') || !/<\/proposed_plan>\s*$/.test(trimmed))
5538
+ return undefined;
5539
+ const plan = trimmed
5540
+ .slice('<proposed_plan>'.length)
5541
+ .replace(/<\/proposed_plan>\s*$/, '')
5542
+ .trim();
5543
+ return plan.length > 0 ? plan : undefined;
5544
+ }
5341
5545
  function codexOfficialItem(session, nativeTurnId, value, turnSequence, itemSequence, startedAt, completedAt) {
5342
5546
  if (!isPlainRecord(value) || typeof value.type !== 'string')
5343
5547
  return undefined;
@@ -5592,6 +5796,44 @@ function codexUserInputQuestions(value) {
5592
5796
  ? []
5593
5797
  : questions;
5594
5798
  }
5799
+ function claudeUserInputQuestions(value) {
5800
+ if (!Array.isArray(value.questions) || value.questions.length === 0 || value.questions.length > 4)
5801
+ return [];
5802
+ const questions = value.questions.map((raw, index) => {
5803
+ if (!isPlainRecord(raw) || typeof raw.question !== 'string')
5804
+ return undefined;
5805
+ const options = Array.isArray(raw.options)
5806
+ ? raw.options.flatMap((option) => {
5807
+ if (!isPlainRecord(option) || typeof option.label !== 'string')
5808
+ return [];
5809
+ const label = compactRunnerText(option.label, 500);
5810
+ return label.length === 0
5811
+ ? []
5812
+ : [
5813
+ {
5814
+ value: label,
5815
+ label,
5816
+ description: typeof option.description === 'string'
5817
+ ? compactRunnerText(option.description, 1_000)
5818
+ : null
5819
+ }
5820
+ ];
5821
+ })
5822
+ : [];
5823
+ const multiSelect = raw.multiSelect === true;
5824
+ return {
5825
+ id: `claude-question-${index}`,
5826
+ prompt: compactRunnerText(raw.question, 5_000),
5827
+ input: options.length === 0 ? 'SHORT_TEXT' : multiSelect ? 'MULTI_SELECT' : 'SINGLE_SELECT',
5828
+ options,
5829
+ allowOther: options.length === 0,
5830
+ multiSelect
5831
+ };
5832
+ });
5833
+ return questions.some((question) => question === undefined)
5834
+ ? []
5835
+ : questions;
5836
+ }
5595
5837
  function codexItemStatus(value, completed) {
5596
5838
  if (value === 'failed')
5597
5839
  return 'FAILED';