@keyou007/agent-bot 0.1.14-alpha.1 → 0.1.14-alpha.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.
Files changed (80) hide show
  1. package/CHANGELOG.md +19 -1
  2. package/README.md +48 -15
  3. package/README.zh.md +48 -15
  4. package/config.example.yaml +1 -1
  5. package/dist/cli/AutostartManager.js +431 -0
  6. package/dist/cli/AutostartManager.js.map +1 -0
  7. package/dist/cli/FeishuAppConfiguration.js +21 -13
  8. package/dist/cli/FeishuAppConfiguration.js.map +1 -1
  9. package/dist/cli/Initializer.js +35 -0
  10. package/dist/cli/Initializer.js.map +1 -1
  11. package/dist/cli/SelfUpdateRunner.js +552 -0
  12. package/dist/cli/SelfUpdateRunner.js.map +1 -0
  13. package/dist/cli/SelfUpdater.js +431 -0
  14. package/dist/cli/SelfUpdater.js.map +1 -0
  15. package/dist/cli/autostartOutput.js +63 -0
  16. package/dist/cli/autostartOutput.js.map +1 -0
  17. package/dist/cli/controlProtocol.js.map +1 -1
  18. package/dist/cli/help.js +94 -18
  19. package/dist/cli/help.js.map +1 -1
  20. package/dist/cli/taskGroupOptions.js +14 -11
  21. package/dist/cli/taskGroupOptions.js.map +1 -1
  22. package/dist/cli/taskListOutput.js +3 -0
  23. package/dist/cli/taskListOutput.js.map +1 -1
  24. package/dist/cli/taskTarget.js +99 -0
  25. package/dist/cli/taskTarget.js.map +1 -0
  26. package/dist/cli.js +661 -58
  27. package/dist/cli.js.map +1 -1
  28. package/dist/codex/CodexRuntime.js +8 -2
  29. package/dist/codex/CodexRuntime.js.map +1 -1
  30. package/dist/commands/CommandRouter.js +16 -1
  31. package/dist/commands/CommandRouter.js.map +1 -1
  32. package/dist/feishu/CardRenderer.js +266 -38
  33. package/dist/feishu/CardRenderer.js.map +1 -1
  34. package/dist/feishu/FeishuMarkdown.js +25 -1
  35. package/dist/feishu/FeishuMarkdown.js.map +1 -1
  36. package/dist/feishu/FeishuMessageClient.js +16 -0
  37. package/dist/feishu/FeishuMessageClient.js.map +1 -1
  38. package/dist/feishu/FeishuTurnPresenter.js +42 -6
  39. package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
  40. package/dist/index.js +136 -2
  41. package/dist/index.js.map +1 -1
  42. package/dist/presentation/OutboundRouter.js +7 -0
  43. package/dist/presentation/OutboundRouter.js.map +1 -1
  44. package/dist/presentation/TurnStateReducer.js +21 -7
  45. package/dist/presentation/TurnStateReducer.js.map +1 -1
  46. package/dist/proxy/ProxySessionController.js +537 -13
  47. package/dist/proxy/ProxySessionController.js.map +1 -1
  48. package/dist/state/StateStore.js +53 -0
  49. package/dist/state/StateStore.js.map +1 -1
  50. package/docs/technical-reference.md +17 -7
  51. package/docs/technical-reference.zh.md +17 -7
  52. package/npm-shrinkwrap.json +2 -2
  53. package/package.json +2 -2
  54. package/skills/agent-bot/SKILL.md +109 -188
  55. package/src/cli/AutostartManager.ts +579 -0
  56. package/src/cli/FeishuAppConfiguration.ts +26 -7
  57. package/src/cli/Initializer.ts +57 -0
  58. package/src/cli/SelfUpdateRunner.ts +817 -0
  59. package/src/cli/SelfUpdater.ts +688 -0
  60. package/src/cli/autostartOutput.ts +75 -0
  61. package/src/cli/controlProtocol.ts +119 -1
  62. package/src/cli/help.ts +94 -18
  63. package/src/cli/taskGroupOptions.ts +27 -17
  64. package/src/cli/taskListOutput.ts +10 -0
  65. package/src/cli/taskTarget.ts +168 -0
  66. package/src/cli.ts +809 -65
  67. package/src/codex/CodexRuntime.ts +8 -2
  68. package/src/commands/CommandRouter.ts +14 -1
  69. package/src/commands/commandTypes.ts +2 -0
  70. package/src/feishu/CardRenderer.ts +313 -35
  71. package/src/feishu/FeishuMarkdown.ts +27 -1
  72. package/src/feishu/FeishuMessageClient.ts +33 -0
  73. package/src/feishu/FeishuTurnPresenter.ts +43 -6
  74. package/src/feishu/types.ts +1 -0
  75. package/src/index.ts +174 -2
  76. package/src/presentation/OutboundRouter.ts +7 -0
  77. package/src/presentation/TurnStateReducer.ts +21 -7
  78. package/src/proxy/ProxySessionController.ts +661 -16
  79. package/src/runtime/types.ts +1 -0
  80. package/src/state/StateStore.ts +56 -0
@@ -141,6 +141,12 @@ const HELP_COMMAND_SECTIONS: Array<{
141
141
  requiresArgument: true,
142
142
  },
143
143
  { command: "/sessions", usage: "[关键词]", description: "查找本机任务" },
144
+ {
145
+ command: "/archive",
146
+ usage: "[序号或任务 ID]",
147
+ description: "归档当前或指定任务",
148
+ },
149
+ { command: "/dismiss", description: "归档当前任务并解散当前群" },
144
150
  {
145
151
  command: "/switch",
146
152
  usage: "[序号或任务 ID]",
@@ -493,7 +499,7 @@ export class ProxySessionController {
493
499
  if (isQueueIndependentCommand(command)) {
494
500
  await this.outbound.withReplyTarget(message.contextKey, replyTarget, async () => {
495
501
  try {
496
- if (command.type !== "forkgroup") await this.ensureThreadFork(message);
502
+ if (command.type !== "forkgroup" && command.type !== "dismiss") await this.ensureThreadFork(message);
497
503
  await this.execute(
498
504
  message.contextKey,
499
505
  command,
@@ -655,6 +661,27 @@ export class ProxySessionController {
655
661
  const sessionId = String(scopedAction.value.sessionId ?? "");
656
662
  await this.createFeishuGroupFromReference(contextKey, sessionId, scopedAction.userId);
657
663
  await this.refreshSessionsCardFromAction(scopedAction);
664
+ } else if (kind === "session_archive") {
665
+ const sessionId = String(scopedAction.value.sessionId ?? "");
666
+ await this.archiveSessionReference(contextKey, sessionId, {
667
+ announce: true,
668
+ source: "sessions_card",
669
+ });
670
+ await this.refreshSessionsCardFromAction(scopedAction);
671
+ } else if (kind === "group_dismiss_keep") {
672
+ this.assertDismissGroupRequester(scopedAction);
673
+ await this.outbound.updateInteractiveCard(
674
+ contextKey,
675
+ requiredCardMessageId(scopedAction.messageId),
676
+ this.cardRenderer.renderDismissGroupKept(),
677
+ );
678
+ } else if (kind === "group_dismiss_confirm") {
679
+ this.assertDismissGroupRequester(scopedAction);
680
+ await this.dismissGroupAndArchiveTask(
681
+ contextKey,
682
+ String(scopedAction.value.sessionId ?? ""),
683
+ "command",
684
+ );
658
685
  } else if (kind === "session_stop") {
659
686
  const sessionId = String(scopedAction.value.sessionId ?? "");
660
687
  await this.stopSessionReference(contextKey, sessionId);
@@ -803,6 +830,33 @@ export class ProxySessionController {
803
830
  return `Task stop requested: ${record.title ?? record.remoteSessionId ?? record.localSessionId}`;
804
831
  }
805
832
 
833
+ async controlArchiveTask(localSessionId: string): Promise<{
834
+ localSessionId: string;
835
+ remoteSessionId: string;
836
+ title: string;
837
+ }> {
838
+ const record = this.requireControlSession(localSessionId);
839
+ return this.archiveSessionReference(this.controlSessionContextKey(record), localSessionId, {
840
+ announce: false,
841
+ source: "cli",
842
+ });
843
+ }
844
+
845
+ async controlDismissTask(localSessionId: string): Promise<{
846
+ localSessionId: string;
847
+ remoteSessionId: string;
848
+ title: string;
849
+ chatId: string;
850
+ }> {
851
+ const record = this.store.getSession(localSessionId);
852
+ if (!record) throw new Error(`Task not found: ${localSessionId}`);
853
+ return this.dismissGroupAndArchiveTask(
854
+ this.controlSessionContextKey(record),
855
+ localSessionId,
856
+ "cli",
857
+ );
858
+ }
859
+
806
860
  async controlGetTaskStatus(localSessionId: string): Promise<{
807
861
  session: SessionRecord;
808
862
  snapshot?: TurnViewState;
@@ -984,6 +1038,387 @@ export class ProxySessionController {
984
1038
  };
985
1039
  }
986
1040
 
1041
+ async controlCreateTask(
1042
+ localSessionId: string,
1043
+ requestedTitle?: string,
1044
+ requestedProjectCwd?: string,
1045
+ forceProjectless = false,
1046
+ requestedAgentName?: string,
1047
+ ): Promise<SessionRecord> {
1048
+ const source = this.requireControlSession(localSessionId);
1049
+ const contextKey = this.controlSessionContextKey(source);
1050
+ const context = this.store.getOrCreateUserContext(contextKey, this.config.defaults.agent!);
1051
+ const agentName = requestedAgentName?.trim() || context.defaultAgent;
1052
+ const agent = this.ensureAgent(agentName);
1053
+ if (forceProjectless && agent.kind !== "app-server") {
1054
+ throw new Error("task new --nodir is only available for App Server agents.");
1055
+ }
1056
+ const replyTarget = this.controlSessionReplyTarget(source);
1057
+ const cwd = forceProjectless
1058
+ ? undefined
1059
+ : requestedProjectCwd === undefined
1060
+ ? detectProjectlessWorkspace(source.cwd)
1061
+ ? agent.kind === "app-server" ? undefined : createProjectlessWorkspace().cwd
1062
+ : source.cwd
1063
+ : resolveUserPath(requestedProjectCwd, source.cwd);
1064
+ const settings: SessionExecutionSettings = source.agentName === agentName
1065
+ ? {
1066
+ modelProvider: source.modelProvider,
1067
+ model: source.model,
1068
+ reasoningEffort: source.reasoningEffort,
1069
+ permissionMode: source.permissionMode,
1070
+ }
1071
+ : {};
1072
+ return this.outbound.withReplyTarget(contextKey, replyTarget, () =>
1073
+ this.createSession(
1074
+ contextKey,
1075
+ agentName,
1076
+ cwd,
1077
+ true,
1078
+ false,
1079
+ undefined,
1080
+ undefined,
1081
+ requestedTitle,
1082
+ settings,
1083
+ ));
1084
+ }
1085
+
1086
+ async controlForkTask(localSessionId: string): Promise<{
1087
+ sourceLocalSessionId: string;
1088
+ sourceTurnId: string;
1089
+ task: SessionRecord;
1090
+ }> {
1091
+ const source = this.requireControlSession(localSessionId);
1092
+ const contextKey = this.controlSessionContextKey(source);
1093
+ const replyTarget = this.controlSessionReplyTarget(source);
1094
+ const plan = await this.prepareForkSession(contextKey, source.localSessionId);
1095
+ const forked = await this.outbound.withReplyTarget(contextKey, replyTarget, async () => {
1096
+ const result = await this.forkSessionIntoContext(contextKey, plan);
1097
+ await this.outbound.sendText(
1098
+ contextKey,
1099
+ `已从指定任务${plan.forkedFromHistoricalTurn ? "最近已完成轮次" : ""}创建分支并切换到新任务:${
1100
+ result.session.title
1101
+ ? `${result.session.title}(${result.session.remoteSessionId})`
1102
+ : result.session.remoteSessionId
1103
+ }`,
1104
+ );
1105
+ return result;
1106
+ });
1107
+ return {
1108
+ sourceLocalSessionId: source.localSessionId,
1109
+ sourceTurnId: plan.lastTurnId,
1110
+ task: this.store.getSession(forked.record.localSessionId) ?? forked.record,
1111
+ };
1112
+ }
1113
+
1114
+ async controlSwitchTask(
1115
+ localSessionId: string,
1116
+ targetLocalSessionId?: string,
1117
+ previous = false,
1118
+ ): Promise<SessionRecord> {
1119
+ const anchor = this.requireControlSession(localSessionId);
1120
+ const contextKey = this.controlSessionContextKey(anchor);
1121
+ const replyTarget = this.controlSessionReplyTarget(anchor);
1122
+ const target = previous ? undefined : targetLocalSessionId ?? anchor.localSessionId;
1123
+ await this.outbound.withReplyTarget(contextKey, replyTarget, () => this.switchSession(contextKey, target));
1124
+ return this.requireCurrentSession(contextKey);
1125
+ }
1126
+
1127
+ async controlQueueTaskPrompt(localSessionId: string, text: string): Promise<{ promptId: string; queued: number }> {
1128
+ const promptText = text.trim();
1129
+ if (!promptText) throw new Error("The queued Prompt cannot be empty.");
1130
+ const record = this.requireControlSession(localSessionId);
1131
+ const contextKey = this.controlSessionContextKey(record);
1132
+ const replyTarget = this.controlSessionReplyTarget(record);
1133
+ const queued = this.persistQueuedPrompt(record.localSessionId, contextKey, promptText, { replyTarget });
1134
+ this.store.audit(contextKey, "queued_prompt_added", {
1135
+ promptId: queued.promptId,
1136
+ localSessionId: record.localSessionId,
1137
+ source: "cli",
1138
+ });
1139
+ const queuedCount = this.store.countQueuedPrompts(record.localSessionId);
1140
+ await this.scheduleNextQueuedPrompt(record.localSessionId);
1141
+ return { promptId: queued.promptId, queued: queuedCount };
1142
+ }
1143
+
1144
+ controlTaskAgent(localSessionId: string, agentName?: string): {
1145
+ current: string;
1146
+ agents: Array<{ name: string; title: string }>;
1147
+ } {
1148
+ const record = this.requireControlSession(localSessionId);
1149
+ const contextKey = this.controlSessionContextKey(record);
1150
+ const context = this.store.getOrCreateUserContext(contextKey, this.config.defaults.agent!);
1151
+ if (agentName) {
1152
+ this.ensureAgent(agentName);
1153
+ this.store.setDefaultAgent(contextKey, agentName);
1154
+ }
1155
+ return {
1156
+ current: agentName ?? context.defaultAgent,
1157
+ agents: Object.entries(this.config.agents).map(([name, agent]) => ({ name, title: agent.title })),
1158
+ };
1159
+ }
1160
+
1161
+ async controlTaskSettings(
1162
+ localSessionId: string,
1163
+ setting?: "provider" | "model" | "thinking" | "permissions",
1164
+ value?: string,
1165
+ ): Promise<{
1166
+ session: SessionRecord;
1167
+ providers: Awaited<ReturnType<NonNullable<AgentRuntime["listModelProviders"]>>>;
1168
+ models: Awaited<ReturnType<AgentRuntime["listModels"]>>;
1169
+ reasoningOptions: Awaited<ReturnType<AgentRuntime["listModels"]>>[number]["supportedReasoningEfforts"];
1170
+ permissionModes: PermissionMode[];
1171
+ }> {
1172
+ const record = this.requireControlSession(localSessionId);
1173
+ const loaded = await this.loadSession(record);
1174
+ if (setting && !value?.trim()) throw new Error(`task ${setting} requires a value.`);
1175
+ const nextValue = value?.trim();
1176
+ if (setting === "provider" && nextValue) {
1177
+ await this.assertModelProvider(loaded, nextValue);
1178
+ const models = await loaded.runtime.listModels();
1179
+ const model = models.find((candidate) => candidate.id === loaded.session.model)
1180
+ ?? models.find((candidate) => candidate.isDefault);
1181
+ if (!model) throw new Error("The current runtime has no model available for a Provider change.");
1182
+ const effort = model.supportedReasoningEfforts.some(
1183
+ (candidate) => candidate.value === loaded.session.reasoningEffort,
1184
+ )
1185
+ ? loaded.session.reasoningEffort
1186
+ : model.defaultReasoningEffort ?? model.supportedReasoningEfforts[0]?.value;
1187
+ if (!effort) throw new Error(`Model ${model.id} has no reasoning effort available for a Provider change.`);
1188
+ if (!loaded.runtime.setExecutionSettings) throw new Error("The current runtime does not support Provider settings.");
1189
+ const updated = await loaded.runtime.setExecutionSettings(record.localSessionId, {
1190
+ modelProvider: nextValue,
1191
+ model: model.id,
1192
+ reasoningEffort: effort,
1193
+ permissionMode: loaded.session.permissionMode,
1194
+ });
1195
+ this.store.updateRuntimeSession(record.localSessionId, {
1196
+ modelProvider: updated.modelProvider ?? nextValue,
1197
+ model: updated.model ?? model.id,
1198
+ reasoningEffort: updated.reasoningEffort ?? effort,
1199
+ permissionMode: updated.permissionMode,
1200
+ });
1201
+ } else if (setting === "model" && nextValue) {
1202
+ const models = await loaded.runtime.listModels();
1203
+ const selected = models.find((candidate) => candidate.id === nextValue);
1204
+ if (!selected) throw new Error(`Unknown model: ${nextValue}`);
1205
+ const currentEffort = loaded.session.reasoningEffort;
1206
+ const nextEffort = currentEffort && selected.supportedReasoningEfforts.some(
1207
+ (candidate) => candidate.value === currentEffort,
1208
+ )
1209
+ ? currentEffort
1210
+ : selected.defaultReasoningEffort;
1211
+ await loaded.runtime.setModel(record.localSessionId, nextValue);
1212
+ if (nextEffort && nextEffort !== currentEffort) {
1213
+ await loaded.runtime.setReasoningEffort(record.localSessionId, nextEffort);
1214
+ }
1215
+ this.store.updateRuntimeSession(record.localSessionId, { model: nextValue, reasoningEffort: nextEffort });
1216
+ } else if (setting === "thinking" && nextValue) {
1217
+ const models = await loaded.runtime.listModels();
1218
+ const currentModel = models.find((candidate) => candidate.id === loaded.session.model)
1219
+ ?? models.find((candidate) => candidate.isDefault);
1220
+ if (!currentModel) throw new Error("The current runtime has no model with configurable reasoning effort.");
1221
+ if (!currentModel.supportedReasoningEfforts.some((candidate) => candidate.value === nextValue)) {
1222
+ const supported = currentModel.supportedReasoningEfforts.map((candidate) => candidate.value).join(", ") || "none";
1223
+ throw new Error(`Unsupported reasoning effort: ${nextValue}. Supported values: ${supported}.`);
1224
+ }
1225
+ await loaded.runtime.setReasoningEffort(record.localSessionId, nextValue);
1226
+ this.store.updateRuntimeSession(record.localSessionId, { reasoningEffort: nextValue });
1227
+ } else if (setting === "permissions" && nextValue) {
1228
+ if (nextValue !== "auto" && nextValue !== "confirm") {
1229
+ throw new Error("task permissions accepts auto or confirm.");
1230
+ }
1231
+ await loaded.runtime.setPermissionMode(record.localSessionId, nextValue);
1232
+ this.store.updateRuntimeSession(record.localSessionId, { permissionMode: nextValue });
1233
+ }
1234
+ const session = this.store.getSession(record.localSessionId) ?? record;
1235
+ const models = await loaded.runtime.listModels();
1236
+ const currentModel = models.find((candidate) => candidate.id === session.model)
1237
+ ?? models.find((candidate) => candidate.isDefault);
1238
+ const providers = loaded.runtime.listModelProviders
1239
+ ? await loaded.runtime.listModelProviders()
1240
+ : [];
1241
+ return {
1242
+ session,
1243
+ providers,
1244
+ models,
1245
+ reasoningOptions: currentModel?.supportedReasoningEfforts ?? [],
1246
+ permissionModes: ["auto", "confirm"],
1247
+ };
1248
+ }
1249
+
1250
+ async controlTaskGoal(
1251
+ localSessionId: string,
1252
+ action: "show" | "set" | "edit" | "pause" | "resume" | "clear",
1253
+ objective?: string,
1254
+ ): Promise<{ goal?: RuntimeGoal; cleared?: boolean }> {
1255
+ const record = this.requireControlSession(localSessionId);
1256
+ const loaded = await this.loadSession(record);
1257
+ if (loaded.runtime.kind !== "codex" || !loaded.runtime.getGoal || !loaded.runtime.setGoal || !loaded.runtime.clearGoal) {
1258
+ throw new Error("The current Agent does not support Goal mode.");
1259
+ }
1260
+ if (action === "show") return { goal: await loaded.runtime.getGoal(record.localSessionId) };
1261
+ if (action === "clear") return { cleared: await loaded.runtime.clearGoal(record.localSessionId) };
1262
+ const current = await loaded.runtime.getGoal(record.localSessionId);
1263
+ if (action === "pause" || action === "resume") {
1264
+ if (!current) throw new Error("The task has no Goal.");
1265
+ return { goal: await loaded.runtime.setGoal(record.localSessionId, { status: action === "pause" ? "paused" : "active" }) };
1266
+ }
1267
+ const nextObjective = objective?.trim();
1268
+ if (!nextObjective) throw new Error(`task goal ${action} requires an objective.`);
1269
+ validateGoalObjective(nextObjective);
1270
+ if (action === "set" && current && current.status !== "complete") {
1271
+ throw new Error("The task already has an unfinished Goal. Use task goal <task> edit <objective>, or clear it first.");
1272
+ }
1273
+ if (action === "edit" && !current) throw new Error("The task has no Goal to edit.");
1274
+ return {
1275
+ goal: await loaded.runtime.setGoal(record.localSessionId, {
1276
+ objective: nextObjective,
1277
+ status: action === "edit" ? current!.status : "active",
1278
+ ...(action === "edit" ? { tokenBudget: current!.tokenBudget } : {}),
1279
+ }),
1280
+ };
1281
+ }
1282
+
1283
+ controlListTaskTurns(localSessionId: string): {
1284
+ session: SessionRecord;
1285
+ turns: Array<{
1286
+ sequence: number;
1287
+ turnId: string;
1288
+ parentTurnId?: string;
1289
+ prompt?: string;
1290
+ startedAt?: number;
1291
+ completedAt?: number;
1292
+ current: boolean;
1293
+ }>;
1294
+ } {
1295
+ const session = this.requireControlSession(localSessionId);
1296
+ if (!session.remoteSessionId || !this.isCodexSession(session)) {
1297
+ throw new Error("The task is not an App Server task that can be reset.");
1298
+ }
1299
+ const contextKey = this.controlSessionContextKey(session);
1300
+ const rows = this.store.listCompletedTurnGraph(session.localSessionId, contextKey);
1301
+ const graph = buildTurnGraphRows(rows.map((turn) => ({ turnId: turn.turnId, parentTurnId: turn.parentTurnId })));
1302
+ return {
1303
+ session,
1304
+ turns: rows.flatMap((turn, index) => {
1305
+ const snapshot = turnViewSnapshot(turn.snapshot);
1306
+ if (!snapshot) return [];
1307
+ return [{
1308
+ sequence: graph[index]!.sequence,
1309
+ turnId: turn.turnId,
1310
+ parentTurnId: turn.parentTurnId,
1311
+ prompt: snapshot.prompt ?? snapshot.taskTitle,
1312
+ startedAt: snapshot.startedAt,
1313
+ completedAt: snapshot.completedAt,
1314
+ current: session.lastTurnId === turn.turnId && session.lastTurnStatus === "completed",
1315
+ }];
1316
+ }),
1317
+ };
1318
+ }
1319
+
1320
+ async controlResetTaskToTurn(localSessionId: string, turnId: string): Promise<SessionRecord> {
1321
+ const record = this.requireControlSession(localSessionId);
1322
+ const contextKey = this.controlSessionContextKey(record);
1323
+ const previous = this.sessionResets.get(record.localSessionId) ?? Promise.resolve();
1324
+ const reset = previous.catch(() => undefined).then(() =>
1325
+ this.performSessionReset(contextKey, record, turnId, false));
1326
+ this.sessionResets.set(record.localSessionId, reset);
1327
+ try {
1328
+ await reset;
1329
+ } finally {
1330
+ if (this.sessionResets.get(record.localSessionId) === reset) this.sessionResets.delete(record.localSessionId);
1331
+ }
1332
+ return this.store.getSession(record.localSessionId) ?? record;
1333
+ }
1334
+
1335
+ controlTaskMute(localSessionId: string, enabled?: boolean): { contextKey: string; enabled: boolean } {
1336
+ const record = this.requireControlSession(localSessionId);
1337
+ const contextKey = baseChatContextKey(this.controlSessionContextKey(record));
1338
+ const chat = this.store.getChatContext(contextKey);
1339
+ if (chat?.chatType !== "group") throw new Error("task mute is only available for group chats.");
1340
+ const next = enabled ?? Boolean(chat.requiresMention);
1341
+ if (enabled !== undefined) this.store.setChatRequiresMention(contextKey, enabled);
1342
+ return { contextKey, enabled: next };
1343
+ }
1344
+
1345
+ async controlRunTaskShell(localSessionId: string, command: string): Promise<ShellCommandResult & {
1346
+ cwd: string;
1347
+ command: string;
1348
+ }> {
1349
+ const record = this.requireControlSession(localSessionId);
1350
+ const normalized = command.trim();
1351
+ if (!normalized) throw new Error("task shell requires a command.");
1352
+ return { ...await this.shellCommandExecutor(normalized, record.cwd), cwd: record.cwd, command: normalized };
1353
+ }
1354
+
1355
+ async controlListTaskDirectory(
1356
+ localSessionId: string,
1357
+ requestedDirectory?: string,
1358
+ requestedPage = 0,
1359
+ ): Promise<{
1360
+ directory: string;
1361
+ parentDirectory?: string;
1362
+ page: number;
1363
+ totalPages: number;
1364
+ totalEntries: number;
1365
+ entries: Array<{
1366
+ name: string;
1367
+ path: string;
1368
+ kind: "directory" | "image" | "binary" | "file" | "drive";
1369
+ }>;
1370
+ }> {
1371
+ const record = this.requireControlSession(localSessionId);
1372
+ if (requestedDirectory === WINDOWS_DRIVES_DIRECTORY) {
1373
+ const drives = await this.windowsDriveLister();
1374
+ const totalPages = Math.max(1, Math.ceil(drives.length / DIRECTORY_PAGE_SIZE));
1375
+ const page = Math.min(Math.max(0, Math.trunc(requestedPage)), totalPages - 1);
1376
+ return {
1377
+ directory: WINDOWS_DRIVES_DIRECTORY,
1378
+ page,
1379
+ totalPages,
1380
+ totalEntries: drives.length,
1381
+ entries: drives.slice(page * DIRECTORY_PAGE_SIZE, (page + 1) * DIRECTORY_PAGE_SIZE).map((drive) => ({
1382
+ name: windowsDriveDisplayName(drive),
1383
+ path: drive.root,
1384
+ kind: "drive" as const,
1385
+ })),
1386
+ };
1387
+ }
1388
+ const directory = requestedDirectory === undefined
1389
+ ? path.resolve(record.cwd)
1390
+ : resolveUserPath(requestedDirectory, record.cwd);
1391
+ await this.assertBrowsableDirectory(directory);
1392
+ const entries = (await fs.readdir(directory, { withFileTypes: true }))
1393
+ .map((entry) => ({
1394
+ name: entry.name,
1395
+ path: path.join(directory, entry.name),
1396
+ kind: directoryBrowserEntryKind(entry),
1397
+ }))
1398
+ .sort((left, right) => Number(right.kind === "directory") - Number(left.kind === "directory")
1399
+ || left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: "base" }));
1400
+ const totalPages = Math.max(1, Math.ceil(entries.length / DIRECTORY_PAGE_SIZE));
1401
+ const page = Math.min(Math.max(0, Math.trunc(requestedPage)), totalPages - 1);
1402
+ const parentDirectory = isWindowsDriveRoot(directory) ? WINDOWS_DRIVES_DIRECTORY : path.dirname(directory);
1403
+ return {
1404
+ directory,
1405
+ ...(parentDirectory !== directory ? { parentDirectory } : {}),
1406
+ page,
1407
+ totalPages,
1408
+ totalEntries: entries.length,
1409
+ entries: entries.slice(page * DIRECTORY_PAGE_SIZE, (page + 1) * DIRECTORY_PAGE_SIZE),
1410
+ };
1411
+ }
1412
+
1413
+ async controlSendTaskFile(localSessionId: string, requestedFilePath: string): Promise<string | undefined> {
1414
+ const record = this.requireControlSession(localSessionId);
1415
+ const filePath = resolveUserPath(requestedFilePath, record.cwd);
1416
+ await this.assertSendableFile(filePath);
1417
+ const contextKey = this.controlSessionContextKey(record);
1418
+ const replyTarget = this.controlSessionReplyTarget(record);
1419
+ return this.outbound.withReplyTarget(contextKey, replyTarget, () => this.outbound.sendFile(contextKey, filePath));
1420
+ }
1421
+
987
1422
  private cardActionContextKey(action: CardAction): string {
988
1423
  const explicit = typeof action.value.contextKey === "string" ? action.value.contextKey : undefined;
989
1424
  if (explicit && baseChatContextKey(explicit) === baseChatContextKey(action.contextKey)) return explicit;
@@ -1079,6 +1514,18 @@ export class ProxySessionController {
1079
1514
  if (command.agent) await this.setDefaultAgent(contextKey, command.agent);
1080
1515
  else await this.openAgentSettings(contextKey);
1081
1516
  return;
1517
+ case "archive": {
1518
+ const sessionId = command.sessionId ?? context.currentSessionId;
1519
+ if (!sessionId) throw new Error("当前会话没有可归档的任务。请使用 /sessions 查找任务。");
1520
+ await this.archiveSessionReference(contextKey, sessionId, {
1521
+ announce: true,
1522
+ source: "command",
1523
+ });
1524
+ return;
1525
+ }
1526
+ case "dismiss":
1527
+ await this.openDismissGroupCard(contextKey, userId);
1528
+ return;
1082
1529
  case "status":
1083
1530
  await this.status(contextKey, command.sessionId);
1084
1531
  return;
@@ -3107,16 +3554,25 @@ export class ProxySessionController {
3107
3554
  if (current.localSessionId !== sessionId) {
3108
3555
  throw new Error("这张思考卡片不属于当前任务。请先切换回对应任务,再点击 Reset。");
3109
3556
  }
3557
+ await this.performSessionReset(contextKey, current, turnId, true);
3558
+ }
3559
+
3560
+ private async performSessionReset(
3561
+ contextKey: string,
3562
+ current: SessionRecord,
3563
+ turnId: string,
3564
+ announce: boolean,
3565
+ ): Promise<void> {
3110
3566
  if (!current.remoteSessionId || !this.isCodexSession(current)) {
3111
3567
  throw new Error("当前任务不是可 Reset 的 App Server 任务。");
3112
3568
  }
3113
3569
 
3114
3570
  const snapshot = turnViewSnapshot(this.store.getTurnSnapshot(turnId));
3115
- if (!snapshot || snapshot.sessionId !== sessionId || snapshot.status !== "completed") {
3571
+ if (!snapshot || snapshot.sessionId !== current.localSessionId || snapshot.status !== "completed") {
3116
3572
  throw new Error("只能将当前任务 Reset 到已成功完成的轮次。");
3117
3573
  }
3118
3574
  if (current.lastTurnId === turnId && current.lastTurnStatus === "completed") {
3119
- await this.outbound.sendText(contextKey, "当前任务已经处于本轮完成后的对话状态,无需 Reset。");
3575
+ if (announce) await this.outbound.sendText(contextKey, "当前任务已经处于本轮完成后的对话状态,无需 Reset。");
3120
3576
  return;
3121
3577
  }
3122
3578
 
@@ -3133,7 +3589,7 @@ export class ProxySessionController {
3133
3589
  }
3134
3590
 
3135
3591
  const origin = this.store.getTurnRuntimeOrigin(turnId);
3136
- if (origin && (origin.localSessionId !== sessionId || origin.agentName !== current.agentName)) {
3592
+ if (origin && (origin.localSessionId !== current.localSessionId || origin.agentName !== current.agentName)) {
3137
3593
  throw new Error("这张思考卡片与当前任务不匹配,已拒绝 Reset。");
3138
3594
  }
3139
3595
  const sourceRemoteSessionId = origin?.remoteSessionId ?? current.remoteSessionId;
@@ -3174,16 +3630,18 @@ export class ProxySessionController {
3174
3630
  100,
3175
3631
  ) || "未记录对话内容";
3176
3632
  const targetTime = snapshot.completedAt ?? snapshot.startedAt;
3177
- await this.outbound.sendText(
3178
- contextKey,
3179
- [
3180
- "已将当前任务重置到:",
3181
- targetSummary,
3182
- `完成时间:${targetTime === undefined ? "未知" : formatResetTurnTime(targetTime)}`,
3183
- `Turn ID:${turnId}`,
3184
- "后续对话将从该轮完成后的状态继续;本地文件没有回退。",
3185
- ].join("\n"),
3186
- );
3633
+ if (announce) {
3634
+ await this.outbound.sendText(
3635
+ contextKey,
3636
+ [
3637
+ "已将当前任务重置到:",
3638
+ targetSummary,
3639
+ `完成时间:${targetTime === undefined ? "未知" : formatResetTurnTime(targetTime)}`,
3640
+ `Turn ID:${turnId}`,
3641
+ "后续对话将从该轮完成后的状态继续;本地文件没有回退。",
3642
+ ].join("\n"),
3643
+ );
3644
+ }
3187
3645
  }
3188
3646
 
3189
3647
  private async resolveApproval(action: CardAction): Promise<void> {
@@ -4112,6 +4570,16 @@ export class ProxySessionController {
4112
4570
  contextKey,
4113
4571
  },
4114
4572
  });
4573
+ actions.push({
4574
+ text: "Archive",
4575
+ value: {
4576
+ action: "session_archive",
4577
+ sessionId: entry.reference,
4578
+ ...(searchTerm ? { searchTerm } : {}),
4579
+ page: String(page),
4580
+ contextKey,
4581
+ },
4582
+ });
4115
4583
  const title = truncateText(entry.title.replace(/\s+/gu, " ").trim() || "未命名任务", 56);
4116
4584
  return {
4117
4585
  project,
@@ -4153,7 +4621,7 @@ export class ProxySessionController {
4153
4621
  `第 ${page + 1} 页 · 每页 ${SESSION_PAGE_SIZE} 个任务${hasNext ? "" : ` · 当前共 ${entries.length} 个任务`}`,
4154
4622
  "",
4155
4623
  "> 项目行:**New** 新建任务,**NewGroup** 新建群。",
4156
- "> 任务详情:**Switch** 切换,**Stop** 停止,**Fork** / **ForkGroup** 创建分支,**Status** 查看状态。",
4624
+ "> 任务详情:**Switch** 切换,**Stop** 停止,**Fork** / **ForkGroup** 创建分支,**Status** 查看状态,**Archive** 归档。",
4157
4625
  ],
4158
4626
  [
4159
4627
  ...(hasPrevious ? [{
@@ -4342,6 +4810,162 @@ export class ProxySessionController {
4342
4810
  await this.outbound.sendText(contextKey, `已向 Agent 发送 Interrupt 请求:${turnId}`);
4343
4811
  }
4344
4812
 
4813
+ private async archiveSessionReference(
4814
+ contextKey: string,
4815
+ reference: string,
4816
+ options: { announce: boolean; source: "cli" | "command" | "dismiss" | "sessions_card" },
4817
+ ): Promise<{ localSessionId: string; remoteSessionId: string; title: string }> {
4818
+ const taskId = this.resolveSessionReference(contextKey, reference);
4819
+ const direct = this.store.getSession(taskId);
4820
+ if (direct?.status === "closed") throw new Error(`任务已经归档:${direct.title ?? taskId}`);
4821
+ const local = direct ?? this.findStoredSessionByReference(taskId);
4822
+ if (local && (!local.remoteSessionId || !this.isCodexSession(local))) {
4823
+ throw new Error("当前 Agent 不支持归档任务。");
4824
+ }
4825
+ if (local && (
4826
+ local.status === "running"
4827
+ || Boolean(this.runtimes.forAgent(local.agentName).getSession(local.localSessionId)?.activeTurnId)
4828
+ )) {
4829
+ throw new Error("任务正在执行,无法归档。请先停止任务或等待本轮完成。");
4830
+ }
4831
+ if (local && this.store.countQueuedPrompts(local.localSessionId) > 0) {
4832
+ throw new Error("任务仍有排队消息,无法归档。请先处理或取消排队消息。");
4833
+ }
4834
+
4835
+ const remoteReference = local
4836
+ ? remoteSessionReference(local.agentName, local.remoteSessionId!)
4837
+ : taskId;
4838
+ const { agentName, runtime, remote } = await this.resolveRemoteCodexSession(remoteReference);
4839
+ if (isRemoteSessionActive(remote)) {
4840
+ throw new Error("任务正在执行,无法归档。请先停止任务或等待本轮完成。");
4841
+ }
4842
+ if (!runtime.archiveRemoteSession) {
4843
+ throw new Error(`Agent ${agentName} 不支持归档任务。`);
4844
+ }
4845
+
4846
+ const wasCurrent = Boolean(local && this.store.getUserContext(contextKey)?.currentSessionId === local.localSessionId);
4847
+ await runtime.archiveRemoteSession(remote.id);
4848
+ const title = local?.title ?? remote.title ?? remote.preview ?? remote.id;
4849
+ if (local) {
4850
+ this.store.archiveSession(local.localSessionId);
4851
+ this.outbound.unregisterSession(local.localSessionId);
4852
+ }
4853
+ this.store.audit(contextKey, "session_archived", {
4854
+ ...(local ? { localSessionId: local.localSessionId } : {}),
4855
+ remoteSessionId: remote.id,
4856
+ agentName,
4857
+ source: options.source,
4858
+ });
4859
+ if (options.announce) {
4860
+ await this.outbound.sendText(
4861
+ contextKey,
4862
+ wasCurrent
4863
+ ? `已归档任务:${title}\n当前会话已没有绑定任务,直接发送消息即可创建新任务。`
4864
+ : `已归档任务:${title}`,
4865
+ );
4866
+ }
4867
+ return {
4868
+ localSessionId: local?.localSessionId ?? "",
4869
+ remoteSessionId: remote.id,
4870
+ title,
4871
+ };
4872
+ }
4873
+
4874
+ private async openDismissGroupCard(contextKey: string, userId?: string): Promise<void> {
4875
+ const groupContextKey = this.dismissibleGroupContextKey(contextKey);
4876
+ if (!userId) throw new Error("无法识别当前用户,不能发起解散群操作。");
4877
+ const session = this.requireCurrentSession(groupContextKey);
4878
+ await this.outbound.sendInteractiveCard(groupContextKey, this.cardRenderer.renderDismissGroupConfirmation({
4879
+ contextKey: groupContextKey,
4880
+ sessionId: session.localSessionId,
4881
+ taskTitle: session.title ?? session.remoteSessionId ?? session.localSessionId,
4882
+ requestedBy: userId,
4883
+ }));
4884
+ }
4885
+
4886
+ private assertDismissGroupRequester(action: CardAction): void {
4887
+ const requestedBy = String(action.value.requestedBy ?? "");
4888
+ if (!requestedBy || !action.userId || action.userId !== requestedBy) {
4889
+ throw new Error("只有发起解散操作的用户可以点击这张卡片。");
4890
+ }
4891
+ }
4892
+
4893
+ private async dismissGroupAndArchiveTask(
4894
+ contextKey: string,
4895
+ localSessionId: string,
4896
+ source: "cli" | "command",
4897
+ ): Promise<{ localSessionId: string; remoteSessionId: string; title: string; chatId: string }> {
4898
+ const groupContextKey = this.dismissibleGroupContextKey(contextKey);
4899
+ const session = this.store.getSessionForContext(localSessionId, groupContextKey);
4900
+ if (!session) throw new Error("解散群卡片已失效,请重新发送 /dismiss。");
4901
+
4902
+ const currentSessionId = this.store.getUserContext(groupContextKey)?.currentSessionId;
4903
+ if (session.status !== "closed" && currentSessionId !== localSessionId) {
4904
+ throw new Error("当前任务已经切换,解散群卡片已失效,请重新发送 /dismiss。");
4905
+ }
4906
+ this.assertNoOtherActiveGroupTasks(groupContextKey, localSessionId);
4907
+
4908
+ const archived = session.status === "closed"
4909
+ ? {
4910
+ localSessionId: session.localSessionId,
4911
+ remoteSessionId: session.remoteSessionId ?? session.acpSessionId ?? "",
4912
+ title: session.title ?? session.remoteSessionId ?? session.localSessionId,
4913
+ }
4914
+ : await this.archiveSessionReference(groupContextKey, localSessionId, {
4915
+ announce: false,
4916
+ source: "dismiss",
4917
+ });
4918
+ const chatId = groupContextKey.slice("chat_id:".length);
4919
+ try {
4920
+ await this.outbound.deleteGroup(groupContextKey, chatId);
4921
+ } catch (error) {
4922
+ const message = error instanceof Error ? error.message : String(error);
4923
+ throw new Error(`当前任务已归档,但解散群失败:${message}`);
4924
+ }
4925
+
4926
+ const detachedSessionIds = this.store.removeChatContext(groupContextKey);
4927
+ for (const detachedSessionId of detachedSessionIds) {
4928
+ const linked = this.store.getSession(detachedSessionId);
4929
+ const routedContextKey = this.outbound.getSessionContextKey(detachedSessionId) ?? linked?.contextKey;
4930
+ if (routedContextKey && baseChatContextKey(routedContextKey) === groupContextKey) {
4931
+ this.outbound.unregisterSession(detachedSessionId);
4932
+ }
4933
+ }
4934
+ this.store.audit(groupContextKey, "group_dismissed", {
4935
+ chatId,
4936
+ localSessionId,
4937
+ remoteSessionId: archived.remoteSessionId,
4938
+ source,
4939
+ });
4940
+ return { ...archived, chatId };
4941
+ }
4942
+
4943
+ private assertNoOtherActiveGroupTasks(contextKey: string, dismissedSessionId: string): void {
4944
+ const active = this.store.listSessionsForChat(contextKey).find((session) => (
4945
+ session.localSessionId !== dismissedSessionId
4946
+ && session.status !== "closed"
4947
+ && (
4948
+ session.status === "running"
4949
+ || Boolean(this.runtimes.forAgent(session.agentName).getSession(session.localSessionId)?.activeTurnId)
4950
+ || this.store.countQueuedPrompts(session.localSessionId) > 0
4951
+ )
4952
+ ));
4953
+ if (active) {
4954
+ throw new Error(`群内任务仍在执行或有排队消息,暂时不能解散:${active.title ?? active.localSessionId}`);
4955
+ }
4956
+ }
4957
+
4958
+ private dismissibleGroupContextKey(contextKey: string): string {
4959
+ if (isThreadContextKey(contextKey)) {
4960
+ throw new Error("/dismiss 不能在话题中使用,请回到群聊正文后重试。");
4961
+ }
4962
+ const groupContextKey = baseChatContextKey(contextKey);
4963
+ if (!groupContextKey.startsWith("chat_id:") || this.store.getChatContext(groupContextKey)?.chatType !== "group") {
4964
+ throw new Error("/dismiss 仅适用于普通群聊。");
4965
+ }
4966
+ return groupContextKey;
4967
+ }
4968
+
4345
4969
  private resolveSessionReference(contextKey: string, reference?: string): string {
4346
4970
  if (reference === undefined) {
4347
4971
  const previous = this.store.getOrCreateUserContext(contextKey, this.config.defaults.agent!).previousSessionId;
@@ -4687,6 +5311,27 @@ export class ProxySessionController {
4687
5311
  );
4688
5312
  }
4689
5313
 
5314
+ private requireControlSession(localSessionId: string): SessionRecord {
5315
+ const record = this.store.getSession(localSessionId);
5316
+ if (!record || record.status === "closed") throw new Error(`Task not found: ${localSessionId}`);
5317
+ return record;
5318
+ }
5319
+
5320
+ private controlSessionContextKey(record: SessionRecord): string {
5321
+ return this.outbound.getSessionContextKey(record.localSessionId)
5322
+ ?? (record.lastTurnId ? this.store.getTurnContextKey(record.lastTurnId) : undefined)
5323
+ ?? record.contextKey;
5324
+ }
5325
+
5326
+ private controlSessionReplyTarget(record: SessionRecord): MessageReplyTarget | undefined {
5327
+ const routed = this.outbound.getSessionReplyTarget(record.localSessionId);
5328
+ if (routed) return routed;
5329
+ const snapshot = record.lastTurnId
5330
+ ? turnViewSnapshot(this.store.getTurnSnapshot(record.lastTurnId))
5331
+ : undefined;
5332
+ return snapshot?.replyTarget;
5333
+ }
5334
+
4690
5335
  private ensureAgent(agentName: string) {
4691
5336
  const agent = this.config.agents[agentName];
4692
5337
  if (!agent) throw new Error(`未知 agent:${agentName}`);
@@ -5246,7 +5891,7 @@ function isBotOwnedActiveTurn(record: SessionRecord, remote: RemoteSessionSummar
5246
5891
  }
5247
5892
 
5248
5893
  function isQueueIndependentCommand(command: Command): boolean {
5249
- if (["stop", "status", "restart", "mute", "help", "sessions", "dir", "goal", "nosteer"].includes(command.type)) return true;
5894
+ if (["archive", "dismiss", "stop", "status", "restart", "mute", "help", "sessions", "dir", "goal", "nosteer"].includes(command.type)) return true;
5250
5895
  if (command.type === "agent") return command.agent === undefined;
5251
5896
  if (["model", "provider", "thinking", "permissions"].includes(command.type)) return true;
5252
5897
  return false;