@myagentroam/node 0.1.1 → 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.
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, 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;
@@ -99,6 +100,7 @@ export class NodeConnector {
99
100
  reconnectTimer;
100
101
  terminalReconnectTimer;
101
102
  attempts = 0;
103
+ terminalAttempts = 0;
102
104
  stopped = false;
103
105
  config;
104
106
  capabilitiesProvided;
@@ -158,6 +160,7 @@ export class NodeConnector {
158
160
  /** Bounded Runner diagnostic paired with a fixed external resume reason code. */
159
161
  externalResumeFailureDetails = new Map();
160
162
  claudeApprovals = new Map();
163
+ claudeUserInputs = new Map();
161
164
  commandStates = new CommandStateStore();
162
165
  runnerUsageReader;
163
166
  codexFastEnabled = false;
@@ -234,6 +237,11 @@ export class NodeConnector {
234
237
  approval.resolve('REJECTED');
235
238
  }
236
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();
237
245
  for (const timer of this.queuePauseWindows.values())
238
246
  clearTimeout(timer);
239
247
  this.queuePauseWindows.clear();
@@ -410,7 +418,7 @@ export class NodeConnector {
410
418
  this.terminalReconnectTimer = setTimeout(() => {
411
419
  this.terminalReconnectTimer = undefined;
412
420
  this.connectTerminal();
413
- }, RETRY_MIN_MS);
421
+ }, nextReconnectDelay(this.terminalAttempts++));
414
422
  }
415
423
  handleTerminalMessage(raw) {
416
424
  let message;
@@ -430,6 +438,9 @@ export class NodeConnector {
430
438
  Object.keys(message).some((key) => key !== 'type' && key !== 'nodeId')) {
431
439
  this.terminalSocket?.close(4002, 'TERMINAL_MESSAGE_INVALID');
432
440
  }
441
+ else {
442
+ this.terminalAttempts = 0;
443
+ }
433
444
  return;
434
445
  }
435
446
  if (message.type === 'attach') {
@@ -1077,16 +1088,20 @@ export class NodeConnector {
1077
1088
  return this.currentChangesForWorkspace(workspace);
1078
1089
  }
1079
1090
  if (operation === 'workspace.change.diff') {
1080
- 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'))
1081
1094
  throw new Error('GIT_DIFF_INVALID');
1082
1095
  const workspace = this.requireWorkspace(input.workspaceId);
1083
1096
  if (workspace.kind !== 'GIT_WORKSPACE')
1084
1097
  throw new Error('WORKSPACE_NOT_GIT');
1085
- 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) };
1086
1100
  }
1087
1101
  if (operation === 'workspace.change.restore') {
1088
1102
  if (typeof input.workspaceId !== 'string' ||
1089
1103
  typeof input.path !== 'string' ||
1104
+ (input.repositoryPath !== undefined && typeof input.repositoryPath !== 'string') ||
1090
1105
  (input.state !== 'STAGED' && input.state !== 'UNSTAGED' && input.state !== 'UNTRACKED'))
1091
1106
  throw new Error('GIT_CHANGE_RESTORE_UNAVAILABLE');
1092
1107
  const workspace = this.requireWorkspace(input.workspaceId);
@@ -1094,7 +1109,8 @@ export class NodeConnector {
1094
1109
  throw new Error('WORKSPACE_NOT_GIT');
1095
1110
  if (this.runtime.hasActiveWorkspaceLease(workspace.id))
1096
1111
  throw new Error('WORKSPACE_BUSY');
1097
- await restoreCurrentChange(workspace.path, input.path, input.state, this.fileAccessOptions());
1112
+ const repository = await resolveGitRepository(workspace.path, input.repositoryPath ?? '');
1113
+ await restoreCurrentChange(repository, input.path, input.state);
1098
1114
  // A read started before the write must not satisfy the explicit post-write refresh.
1099
1115
  this.invalidateWorkspaceWatchTopics(workspace.id);
1100
1116
  return { restored: true };
@@ -1559,12 +1575,16 @@ export class NodeConnector {
1559
1575
  const runnerInput = withNonImageAttachmentPrompt(input.content, attachmentPaths);
1560
1576
  this.runtime.updateQueuedMessage(created.run.id, runnerInput);
1561
1577
  const commandStates = this.commandStates.list(session.id);
1578
+ const planActive = commandStates.some((state) => state.commandId === 'plan');
1562
1579
  this.queuedRunStarts.set(created.run.id, {
1563
1580
  runId: created.run.id,
1564
1581
  sessionId: created.run.sessionId,
1565
1582
  workspaceId: created.run.workspaceId,
1566
1583
  runner: created.run.runner,
1567
- input: runnerInput,
1584
+ // Claude 标签式 Plan Mode 只包装发往 Runner 的输入,用户气泡保持原文。
1585
+ input: created.run.runner === 'claude-code' && planActive
1586
+ ? withClaudePlanTag(runnerInput)
1587
+ : runnerInput,
1568
1588
  attachments: runnerImageAttachments,
1569
1589
  attachmentPaths,
1570
1590
  cwd: session.cwd,
@@ -1573,9 +1593,7 @@ export class NodeConnector {
1573
1593
  effort: session.effort,
1574
1594
  // Plan 只切换 Codex 协作模式,不得覆盖会话已选权限。
1575
1595
  access: session.access,
1576
- collaborationMode: commandStates.some((state) => state.commandId === 'plan')
1577
- ? 'plan'
1578
- : 'default',
1596
+ collaborationMode: planActive ? 'plan' : 'default',
1579
1597
  ...(this.codexFastEnabled ? { serviceTier: 'fast' } : {})
1580
1598
  });
1581
1599
  this.runtime.resumeSessionQueue(session.id);
@@ -1647,7 +1665,12 @@ export class NodeConnector {
1647
1665
  const runnerInput = withNonImageAttachmentPrompt(input.content.trim(), pending?.attachmentPaths ?? []);
1648
1666
  this.runtime.updateQueuedMessage(input.runId, runnerInput);
1649
1667
  if (pending !== undefined)
1650
- 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
+ });
1651
1674
  this.emitWorkspaceQueue(run.workspaceId, run.sessionId);
1652
1675
  return { queue: this.presentWorkspaceQueue(run.workspaceId, run.sessionId) };
1653
1676
  }
@@ -1746,7 +1769,7 @@ export class NodeConnector {
1746
1769
  typeof input.requestId !== 'string' ||
1747
1770
  !isPlainRecord(input.answers))
1748
1771
  throw new Error('USER_INPUT_INVALID');
1749
- this.respondCodexUserInput(input.runId, input.requestId, input.answers);
1772
+ this.respondUserInput(input.runId, input.requestId, input.answers);
1750
1773
  return { accepted: true };
1751
1774
  }
1752
1775
  if (operation === 'session.interrupt') {
@@ -2006,13 +2029,6 @@ export class NodeConnector {
2006
2029
  this.workspaceChangesCache.delete(workspaceId);
2007
2030
  this.workspaceChangesVersions.set(workspaceId, (this.workspaceChangesVersions.get(workspaceId) ?? 0) + 1);
2008
2031
  }
2009
- fileAccessOptions() {
2010
- if (this.config === undefined)
2011
- return {};
2012
- // The database directory is Node-private even when an operator has
2013
- // accidentally placed it below an otherwise permitted Workspace root.
2014
- return { privateRoots: [dirname(nodeDatabasePath(this.config, this.configPath))] };
2015
- }
2016
2032
  async listFilesForWorkspace(workspace, input) {
2017
2033
  if ((input.path !== undefined && typeof input.path !== 'string') ||
2018
2034
  (input.cursor !== undefined && typeof input.cursor !== 'string') ||
@@ -2021,7 +2037,7 @@ export class NodeConnector {
2021
2037
  input.limit < 1 ||
2022
2038
  input.limit > 200)))
2023
2039
  throw new Error('DIRECTORY_CURSOR_INVALID');
2024
- return listWorkspaceFiles(workspace.path, typeof input.path === 'string' ? input.path : '.', typeof input.cursor === 'string' ? input.cursor : undefined, typeof input.limit === 'number' ? input.limit : undefined, this.fileAccessOptions());
2040
+ return listWorkspaceFiles(workspace.path, typeof input.path === 'string' ? input.path : '.', typeof input.cursor === 'string' ? input.cursor : undefined, typeof input.limit === 'number' ? input.limit : undefined);
2025
2041
  }
2026
2042
  async readFileForWorkspace(workspace, input) {
2027
2043
  if (typeof input.path !== 'string' ||
@@ -2033,8 +2049,8 @@ export class NodeConnector {
2033
2049
  input.limit > 512 * 1024)))
2034
2050
  throw new Error('FILE_RANGE_INVALID');
2035
2051
  if (isAbsolute(input.path))
2036
- return readAllowedTextFile(input.path, this.config?.allowedRoots ?? [], typeof input.offset === 'number' ? input.offset : 0, typeof input.limit === 'number' ? input.limit : 512 * 1024, this.fileAccessOptions());
2037
- return readWorkspaceTextFile(workspace.path, input.path, typeof input.offset === 'number' ? input.offset : 0, typeof input.limit === 'number' ? input.limit : 512 * 1024, this.fileAccessOptions());
2052
+ return readAllowedTextFile(input.path, this.config?.allowedRoots ?? [], typeof input.offset === 'number' ? input.offset : 0, typeof input.limit === 'number' ? input.limit : 512 * 1024);
2053
+ return readWorkspaceTextFile(workspace.path, input.path, typeof input.offset === 'number' ? input.offset : 0, typeof input.limit === 'number' ? input.limit : 512 * 1024);
2038
2054
  }
2039
2055
  async searchFilesForWorkspace(workspace, input) {
2040
2056
  if (typeof input.query !== 'string' ||
@@ -2058,7 +2074,7 @@ export class NodeConnector {
2058
2074
  this.workspaceFileIndexes.delete(oldestWorkspaceId);
2059
2075
  }
2060
2076
  }
2061
- index = new WorkspaceFileIndex(workspace.path, this.fileAccessOptions(), {
2077
+ index = new WorkspaceFileIndex(workspace.path, {
2062
2078
  onInvalidated: () => this.invalidateWorkspaceWatchTopics(workspace.id)
2063
2079
  });
2064
2080
  }
@@ -2078,7 +2094,7 @@ export class NodeConnector {
2078
2094
  if (seen.has(name))
2079
2095
  return { name, status: 'INVALID_NAME', path: null };
2080
2096
  seen.add(name);
2081
- const target = await preflightWorkspaceUpload(workspace.path, typeof input.directory === 'string' ? input.directory : '.', name, this.fileAccessOptions());
2097
+ const target = await preflightWorkspaceUpload(workspace.path, typeof input.directory === 'string' ? input.directory : '.', name);
2082
2098
  return { name, status: target.status, path: target.path || null };
2083
2099
  }));
2084
2100
  }
@@ -2091,8 +2107,6 @@ export class NodeConnector {
2091
2107
  typeof input.overwrite !== 'boolean' ||
2092
2108
  (composerAttachment !== undefined && input.directory !== undefined))
2093
2109
  throw new Error('UPLOAD_INVALID');
2094
- if (composerAttachment === undefined && this.runtime.hasActiveWorkspaceLease(workspace.id))
2095
- throw new Error('WORKSPACE_BUSY');
2096
2110
  if (composerAttachment !== undefined) {
2097
2111
  if (!validComposerAttachmentName(input.name) ||
2098
2112
  input.size > MAX_COMPOSER_ATTACHMENT_BYTES ||
@@ -2101,7 +2115,7 @@ export class NodeConnector {
2101
2115
  throw new Error('COMPOSER_ATTACHMENT_INVALID');
2102
2116
  }
2103
2117
  const target = composerAttachment === undefined
2104
- ? await preflightWorkspaceUpload(workspace.path, typeof input.directory === 'string' ? input.directory : '.', input.name, this.fileAccessOptions())
2118
+ ? await preflightWorkspaceUpload(workspace.path, typeof input.directory === 'string' ? input.directory : '.', input.name)
2105
2119
  : await this.composerAttachmentTarget(workspace.path, composerAttachment.id, input.name);
2106
2120
  if (target.status === 'INVALID_NAME')
2107
2121
  throw new Error('UPLOAD_NAME_INVALID');
@@ -2303,18 +2317,56 @@ export class NodeConnector {
2303
2317
  return existing;
2304
2318
  const version = this.workspaceChangesVersions.get(workspace.id) ?? 0;
2305
2319
  const request = this.workspaceChangesLimiter.run(async () => {
2306
- const summary = await readCurrentChangesSummary(workspace.path);
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];
2307
2349
  const value = {
2308
- branch: summary.branch,
2309
- changes: summary.changes.map((change) => ({
2350
+ branch: root.branch,
2351
+ changes: root.changes.map((change) => ({
2310
2352
  ...change,
2311
2353
  additions: null,
2312
2354
  deletions: null,
2313
- // Current status does not read file bodies. Detailed binary/diff data
2314
- // remains an explicit on-demand Git request rather than a background
2315
- // scan of the Workspace.
2316
2355
  binary: false,
2317
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
+ }))
2318
2370
  }))
2319
2371
  };
2320
2372
  if ((this.workspaceChangesVersions.get(workspace.id) ?? 0) === version)
@@ -3310,6 +3362,8 @@ export class NodeConnector {
3310
3362
  this.emitRunEvent(runId, 'run.rejected', { code: 'EXTERNAL_THREAD_OBSERVE_ONLY' }, 'FAILED');
3311
3363
  return;
3312
3364
  }
3365
+ if (typeof payload.externalSessionId === 'string')
3366
+ this.completedClaudeContextReads.delete(`claude:${cwd}:${payload.externalSessionId}`);
3313
3367
  this.activeRuns.add(runId);
3314
3368
  void (async () => {
3315
3369
  try {
@@ -3449,7 +3503,10 @@ export class NodeConnector {
3449
3503
  dataBase64: attachment.dataBase64
3450
3504
  }))
3451
3505
  }),
3452
- 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),
3453
3510
  onChannelReply: (content) => this.emitRunEvent(runId, 'channel.reply', { content }),
3454
3511
  onMessage: (message) => {
3455
3512
  const record = message;
@@ -3472,7 +3529,15 @@ export class NodeConnector {
3472
3529
  }
3473
3530
  if (record.type === 'assistant') {
3474
3531
  const text = extractClaudeText(record);
3475
- 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)
3476
3541
  this.emitRunEvent(runId, 'text.delta', { text });
3477
3542
  for (const [index, text] of extractClaudeThinkingSummaries(record).entries()) {
3478
3543
  this.emitConversationItem(runId, {
@@ -3483,6 +3548,10 @@ export class NodeConnector {
3483
3548
  });
3484
3549
  }
3485
3550
  for (const tool of extractClaudeToolUses(record)) {
3551
+ if (tool.name === 'AskUserQuestion') {
3552
+ toolKinds.set(tool.id, 'user_input_request');
3553
+ continue;
3554
+ }
3486
3555
  const itemId = boundedConversationItemId('claude-tool', tool.id);
3487
3556
  const kind = isClaudeCommandTool(tool.name) ? 'command_execution' : 'tool_call';
3488
3557
  toolKinds.set(tool.id, kind);
@@ -3533,6 +3602,8 @@ export class NodeConnector {
3533
3602
  if (record.type === 'user') {
3534
3603
  for (const result of extractClaudeToolResults(record)) {
3535
3604
  const kind = toolKinds.get(result.toolUseId) ?? 'tool_call';
3605
+ if (kind === 'user_input_request')
3606
+ continue;
3536
3607
  this.emitConversationItem(runId, {
3537
3608
  itemId: boundedConversationItemId('claude-tool', result.toolUseId),
3538
3609
  kind,
@@ -3681,6 +3752,41 @@ export class NodeConnector {
3681
3752
  merge: true
3682
3753
  });
3683
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
+ }
3684
3790
  /** Returns true when cancellation already made this Run terminal. */
3685
3791
  completeCancelledRun(runId, cwd) {
3686
3792
  const run = this.runtime.getRun(runId);
@@ -3977,7 +4083,10 @@ export class NodeConnector {
3977
4083
  // Context detail is optional Runner metadata. Cache the miss briefly to
3978
4084
  // avoid spawning one Claude process per browser polling tick.
3979
4085
  }
3980
- 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
+ });
3981
4090
  this.emitWorkbenchEvent('session', {
3982
4091
  sessionId: session.id,
3983
4092
  workspaceId: session.workspaceId,
@@ -3988,9 +4097,27 @@ export class NodeConnector {
3988
4097
  /** Executes only Node-declared interactions; the browser never supplies behavior. */
3989
4098
  async executeRunnerCommand(session, command, input) {
3990
4099
  if (session.runner === 'claude-code') {
3991
- if (command.id !== 'compact')
4100
+ if (command.id === 'compact')
4101
+ return this.startClaudeCompaction(session, input);
4102
+ if (command.id !== 'plan')
4103
+ throw new Error('RUNNER_COMMAND_UNAVAILABLE');
4104
+ // 标签式 Plan Mode 与 SDK 原生 plan 权限不叠加。
4105
+ if (session.access === 'plan')
3992
4106
  throw new Error('RUNNER_COMMAND_UNAVAILABLE');
3993
- return this.startClaudeCompaction(session, input);
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) };
3994
4121
  }
3995
4122
  if (command.id === 'plan') {
3996
4123
  await this.codexClient.start();
@@ -4209,6 +4336,45 @@ export class NodeConnector {
4209
4336
  });
4210
4337
  });
4211
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
+ }
4212
4378
  /**
4213
4379
  * An approval is an individual Runner callback. Keep its exact payload in
4214
4380
  * the process-local snapshot and emit a Run event so an already open
@@ -5042,9 +5208,24 @@ function nativeConversationPage(session, history, input, runtimeTurns = []) {
5042
5208
  const turnId = `${session.id}:native:${index}`;
5043
5209
  const isToolCall = entry.kind === 'tool_call';
5044
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;
5045
5218
  const clientMessageId = !isToolCall && entry.role === 'USER'
5046
5219
  ? nativeUserClientMessageId(entry, time, runtimeTurns)
5047
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;
5048
5229
  const item = {
5049
5230
  id: `${turnId}:item`,
5050
5231
  sessionId: session.id,
@@ -5053,38 +5234,49 @@ function nativeConversationPage(session, history, input, runtimeTurns = []) {
5053
5234
  parentItemId: null,
5054
5235
  sourceSequence: index,
5055
5236
  revision: 0,
5056
- kind: isToolCall
5057
- ? isCommand
5058
- ? 'command_execution'
5059
- : 'tool_call'
5060
- : entry.role === 'USER'
5061
- ? 'user_message'
5062
- : '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',
5063
5248
  status: 'COMPLETED',
5064
- payload: isToolCall
5065
- ? isCommand
5066
- ? {
5067
- command: nativeClaudeCommand(entry),
5068
- cwd: null,
5069
- outputPreview: entry.outputSummary ?? '',
5070
- outputRef: null,
5071
- exitCode: null,
5072
- durationMs: null,
5073
- truncated: entry.inputTruncated || entry.outputTruncated
5074
- }
5075
- : {
5076
- namespace: session.runner,
5077
- toolName: entry.toolName,
5078
- title: entry.toolName,
5079
- inputSummary: entry.inputSummary,
5080
- outputSummary: entry.outputSummary,
5081
- progressLabel: null,
5082
- errorCode: entry.failed ? 'TOOL_EXECUTION_FAILED' : null,
5083
- truncated: entry.inputTruncated || entry.outputTruncated
5084
- }
5085
- : entry.role === 'USER'
5086
- ? { clientMessageId, text: entry.text, contexts: [], delivery: 'ACCEPTED' }
5087
- : { 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' },
5088
5280
  startedAt: time,
5089
5281
  completedAt: time
5090
5282
  };
@@ -5121,7 +5313,8 @@ function nativeUserClientMessageId(entry, createdAt, runtimeTurns) {
5121
5313
  continue;
5122
5314
  const payload = user.payload;
5123
5315
  const clientMessageId = typeof payload.clientMessageId === 'string' ? payload.clientMessageId : null;
5124
- if (clientMessageId === null || payload.text !== entry.text)
5316
+ // 标签式 Plan Mode 的指令标签只存在于原生 transcript,运行态气泡始终是原文。
5317
+ if (clientMessageId === null || payload.text !== stripClaudePlanTag(entry.text))
5125
5318
  continue;
5126
5319
  const runtimeCreatedAt = user.startedAt ?? turn.startedAt;
5127
5320
  if (runtimeCreatedAt !== null &&
@@ -5335,6 +5528,20 @@ function codexPlanText(value) {
5335
5528
  .trim();
5336
5529
  return plan.length > 0 ? plan : undefined;
5337
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
+ }
5338
5545
  function codexOfficialItem(session, nativeTurnId, value, turnSequence, itemSequence, startedAt, completedAt) {
5339
5546
  if (!isPlainRecord(value) || typeof value.type !== 'string')
5340
5547
  return undefined;
@@ -5589,6 +5796,44 @@ function codexUserInputQuestions(value) {
5589
5796
  ? []
5590
5797
  : questions;
5591
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
+ }
5592
5837
  function codexItemStatus(value, completed) {
5593
5838
  if (value === 'failed')
5594
5839
  return 'FAILED';