@keyou007/agent-bot 0.1.14-alpha.1 → 0.1.14-alpha.2

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 (67) hide show
  1. package/CHANGELOG.md +14 -1
  2. package/README.md +42 -12
  3. package/README.zh.md +42 -12
  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 +17 -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 +92 -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 +643 -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 +7 -1
  31. package/dist/commands/CommandRouter.js.map +1 -1
  32. package/dist/feishu/CardRenderer.js +172 -23
  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/index.js +134 -2
  37. package/dist/index.js.map +1 -1
  38. package/dist/proxy/ProxySessionController.js +436 -12
  39. package/dist/proxy/ProxySessionController.js.map +1 -1
  40. package/dist/state/StateStore.js +18 -0
  41. package/dist/state/StateStore.js.map +1 -1
  42. package/docs/technical-reference.md +17 -7
  43. package/docs/technical-reference.zh.md +17 -7
  44. package/npm-shrinkwrap.json +2 -2
  45. package/package.json +2 -2
  46. package/skills/agent-bot/SKILL.md +107 -188
  47. package/src/cli/AutostartManager.ts +579 -0
  48. package/src/cli/FeishuAppConfiguration.ts +22 -7
  49. package/src/cli/Initializer.ts +57 -0
  50. package/src/cli/SelfUpdateRunner.ts +817 -0
  51. package/src/cli/SelfUpdater.ts +688 -0
  52. package/src/cli/autostartOutput.ts +75 -0
  53. package/src/cli/controlProtocol.ts +111 -1
  54. package/src/cli/help.ts +92 -18
  55. package/src/cli/taskGroupOptions.ts +27 -17
  56. package/src/cli/taskListOutput.ts +10 -0
  57. package/src/cli/taskTarget.ts +168 -0
  58. package/src/cli.ts +781 -65
  59. package/src/codex/CodexRuntime.ts +8 -2
  60. package/src/commands/CommandRouter.ts +6 -1
  61. package/src/commands/commandTypes.ts +1 -0
  62. package/src/feishu/CardRenderer.ts +199 -23
  63. package/src/feishu/FeishuMarkdown.ts +27 -1
  64. package/src/index.ts +172 -2
  65. package/src/proxy/ProxySessionController.ts +532 -15
  66. package/src/runtime/types.ts +1 -0
  67. package/src/state/StateStore.ts +19 -0
@@ -141,6 +141,11 @@ 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
+ },
144
149
  {
145
150
  command: "/switch",
146
151
  usage: "[序号或任务 ID]",
@@ -655,6 +660,13 @@ export class ProxySessionController {
655
660
  const sessionId = String(scopedAction.value.sessionId ?? "");
656
661
  await this.createFeishuGroupFromReference(contextKey, sessionId, scopedAction.userId);
657
662
  await this.refreshSessionsCardFromAction(scopedAction);
663
+ } else if (kind === "session_archive") {
664
+ const sessionId = String(scopedAction.value.sessionId ?? "");
665
+ await this.archiveSessionReference(contextKey, sessionId, {
666
+ announce: true,
667
+ source: "sessions_card",
668
+ });
669
+ await this.refreshSessionsCardFromAction(scopedAction);
658
670
  } else if (kind === "session_stop") {
659
671
  const sessionId = String(scopedAction.value.sessionId ?? "");
660
672
  await this.stopSessionReference(contextKey, sessionId);
@@ -803,6 +815,18 @@ export class ProxySessionController {
803
815
  return `Task stop requested: ${record.title ?? record.remoteSessionId ?? record.localSessionId}`;
804
816
  }
805
817
 
818
+ async controlArchiveTask(localSessionId: string): Promise<{
819
+ localSessionId: string;
820
+ remoteSessionId: string;
821
+ title: string;
822
+ }> {
823
+ const record = this.requireControlSession(localSessionId);
824
+ return this.archiveSessionReference(this.controlSessionContextKey(record), localSessionId, {
825
+ announce: false,
826
+ source: "cli",
827
+ });
828
+ }
829
+
806
830
  async controlGetTaskStatus(localSessionId: string): Promise<{
807
831
  session: SessionRecord;
808
832
  snapshot?: TurnViewState;
@@ -984,6 +1008,387 @@ export class ProxySessionController {
984
1008
  };
985
1009
  }
986
1010
 
1011
+ async controlCreateTask(
1012
+ localSessionId: string,
1013
+ requestedTitle?: string,
1014
+ requestedProjectCwd?: string,
1015
+ forceProjectless = false,
1016
+ requestedAgentName?: string,
1017
+ ): Promise<SessionRecord> {
1018
+ const source = this.requireControlSession(localSessionId);
1019
+ const contextKey = this.controlSessionContextKey(source);
1020
+ const context = this.store.getOrCreateUserContext(contextKey, this.config.defaults.agent!);
1021
+ const agentName = requestedAgentName?.trim() || context.defaultAgent;
1022
+ const agent = this.ensureAgent(agentName);
1023
+ if (forceProjectless && agent.kind !== "app-server") {
1024
+ throw new Error("task new --nodir is only available for App Server agents.");
1025
+ }
1026
+ const replyTarget = this.controlSessionReplyTarget(source);
1027
+ const cwd = forceProjectless
1028
+ ? undefined
1029
+ : requestedProjectCwd === undefined
1030
+ ? detectProjectlessWorkspace(source.cwd)
1031
+ ? agent.kind === "app-server" ? undefined : createProjectlessWorkspace().cwd
1032
+ : source.cwd
1033
+ : resolveUserPath(requestedProjectCwd, source.cwd);
1034
+ const settings: SessionExecutionSettings = source.agentName === agentName
1035
+ ? {
1036
+ modelProvider: source.modelProvider,
1037
+ model: source.model,
1038
+ reasoningEffort: source.reasoningEffort,
1039
+ permissionMode: source.permissionMode,
1040
+ }
1041
+ : {};
1042
+ return this.outbound.withReplyTarget(contextKey, replyTarget, () =>
1043
+ this.createSession(
1044
+ contextKey,
1045
+ agentName,
1046
+ cwd,
1047
+ true,
1048
+ false,
1049
+ undefined,
1050
+ undefined,
1051
+ requestedTitle,
1052
+ settings,
1053
+ ));
1054
+ }
1055
+
1056
+ async controlForkTask(localSessionId: string): Promise<{
1057
+ sourceLocalSessionId: string;
1058
+ sourceTurnId: string;
1059
+ task: SessionRecord;
1060
+ }> {
1061
+ const source = this.requireControlSession(localSessionId);
1062
+ const contextKey = this.controlSessionContextKey(source);
1063
+ const replyTarget = this.controlSessionReplyTarget(source);
1064
+ const plan = await this.prepareForkSession(contextKey, source.localSessionId);
1065
+ const forked = await this.outbound.withReplyTarget(contextKey, replyTarget, async () => {
1066
+ const result = await this.forkSessionIntoContext(contextKey, plan);
1067
+ await this.outbound.sendText(
1068
+ contextKey,
1069
+ `已从指定任务${plan.forkedFromHistoricalTurn ? "最近已完成轮次" : ""}创建分支并切换到新任务:${
1070
+ result.session.title
1071
+ ? `${result.session.title}(${result.session.remoteSessionId})`
1072
+ : result.session.remoteSessionId
1073
+ }`,
1074
+ );
1075
+ return result;
1076
+ });
1077
+ return {
1078
+ sourceLocalSessionId: source.localSessionId,
1079
+ sourceTurnId: plan.lastTurnId,
1080
+ task: this.store.getSession(forked.record.localSessionId) ?? forked.record,
1081
+ };
1082
+ }
1083
+
1084
+ async controlSwitchTask(
1085
+ localSessionId: string,
1086
+ targetLocalSessionId?: string,
1087
+ previous = false,
1088
+ ): Promise<SessionRecord> {
1089
+ const anchor = this.requireControlSession(localSessionId);
1090
+ const contextKey = this.controlSessionContextKey(anchor);
1091
+ const replyTarget = this.controlSessionReplyTarget(anchor);
1092
+ const target = previous ? undefined : targetLocalSessionId ?? anchor.localSessionId;
1093
+ await this.outbound.withReplyTarget(contextKey, replyTarget, () => this.switchSession(contextKey, target));
1094
+ return this.requireCurrentSession(contextKey);
1095
+ }
1096
+
1097
+ async controlQueueTaskPrompt(localSessionId: string, text: string): Promise<{ promptId: string; queued: number }> {
1098
+ const promptText = text.trim();
1099
+ if (!promptText) throw new Error("The queued Prompt cannot be empty.");
1100
+ const record = this.requireControlSession(localSessionId);
1101
+ const contextKey = this.controlSessionContextKey(record);
1102
+ const replyTarget = this.controlSessionReplyTarget(record);
1103
+ const queued = this.persistQueuedPrompt(record.localSessionId, contextKey, promptText, { replyTarget });
1104
+ this.store.audit(contextKey, "queued_prompt_added", {
1105
+ promptId: queued.promptId,
1106
+ localSessionId: record.localSessionId,
1107
+ source: "cli",
1108
+ });
1109
+ const queuedCount = this.store.countQueuedPrompts(record.localSessionId);
1110
+ await this.scheduleNextQueuedPrompt(record.localSessionId);
1111
+ return { promptId: queued.promptId, queued: queuedCount };
1112
+ }
1113
+
1114
+ controlTaskAgent(localSessionId: string, agentName?: string): {
1115
+ current: string;
1116
+ agents: Array<{ name: string; title: string }>;
1117
+ } {
1118
+ const record = this.requireControlSession(localSessionId);
1119
+ const contextKey = this.controlSessionContextKey(record);
1120
+ const context = this.store.getOrCreateUserContext(contextKey, this.config.defaults.agent!);
1121
+ if (agentName) {
1122
+ this.ensureAgent(agentName);
1123
+ this.store.setDefaultAgent(contextKey, agentName);
1124
+ }
1125
+ return {
1126
+ current: agentName ?? context.defaultAgent,
1127
+ agents: Object.entries(this.config.agents).map(([name, agent]) => ({ name, title: agent.title })),
1128
+ };
1129
+ }
1130
+
1131
+ async controlTaskSettings(
1132
+ localSessionId: string,
1133
+ setting?: "provider" | "model" | "thinking" | "permissions",
1134
+ value?: string,
1135
+ ): Promise<{
1136
+ session: SessionRecord;
1137
+ providers: Awaited<ReturnType<NonNullable<AgentRuntime["listModelProviders"]>>>;
1138
+ models: Awaited<ReturnType<AgentRuntime["listModels"]>>;
1139
+ reasoningOptions: Awaited<ReturnType<AgentRuntime["listModels"]>>[number]["supportedReasoningEfforts"];
1140
+ permissionModes: PermissionMode[];
1141
+ }> {
1142
+ const record = this.requireControlSession(localSessionId);
1143
+ const loaded = await this.loadSession(record);
1144
+ if (setting && !value?.trim()) throw new Error(`task ${setting} requires a value.`);
1145
+ const nextValue = value?.trim();
1146
+ if (setting === "provider" && nextValue) {
1147
+ await this.assertModelProvider(loaded, nextValue);
1148
+ const models = await loaded.runtime.listModels();
1149
+ const model = models.find((candidate) => candidate.id === loaded.session.model)
1150
+ ?? models.find((candidate) => candidate.isDefault);
1151
+ if (!model) throw new Error("The current runtime has no model available for a Provider change.");
1152
+ const effort = model.supportedReasoningEfforts.some(
1153
+ (candidate) => candidate.value === loaded.session.reasoningEffort,
1154
+ )
1155
+ ? loaded.session.reasoningEffort
1156
+ : model.defaultReasoningEffort ?? model.supportedReasoningEfforts[0]?.value;
1157
+ if (!effort) throw new Error(`Model ${model.id} has no reasoning effort available for a Provider change.`);
1158
+ if (!loaded.runtime.setExecutionSettings) throw new Error("The current runtime does not support Provider settings.");
1159
+ const updated = await loaded.runtime.setExecutionSettings(record.localSessionId, {
1160
+ modelProvider: nextValue,
1161
+ model: model.id,
1162
+ reasoningEffort: effort,
1163
+ permissionMode: loaded.session.permissionMode,
1164
+ });
1165
+ this.store.updateRuntimeSession(record.localSessionId, {
1166
+ modelProvider: updated.modelProvider ?? nextValue,
1167
+ model: updated.model ?? model.id,
1168
+ reasoningEffort: updated.reasoningEffort ?? effort,
1169
+ permissionMode: updated.permissionMode,
1170
+ });
1171
+ } else if (setting === "model" && nextValue) {
1172
+ const models = await loaded.runtime.listModels();
1173
+ const selected = models.find((candidate) => candidate.id === nextValue);
1174
+ if (!selected) throw new Error(`Unknown model: ${nextValue}`);
1175
+ const currentEffort = loaded.session.reasoningEffort;
1176
+ const nextEffort = currentEffort && selected.supportedReasoningEfforts.some(
1177
+ (candidate) => candidate.value === currentEffort,
1178
+ )
1179
+ ? currentEffort
1180
+ : selected.defaultReasoningEffort;
1181
+ await loaded.runtime.setModel(record.localSessionId, nextValue);
1182
+ if (nextEffort && nextEffort !== currentEffort) {
1183
+ await loaded.runtime.setReasoningEffort(record.localSessionId, nextEffort);
1184
+ }
1185
+ this.store.updateRuntimeSession(record.localSessionId, { model: nextValue, reasoningEffort: nextEffort });
1186
+ } else if (setting === "thinking" && nextValue) {
1187
+ const models = await loaded.runtime.listModels();
1188
+ const currentModel = models.find((candidate) => candidate.id === loaded.session.model)
1189
+ ?? models.find((candidate) => candidate.isDefault);
1190
+ if (!currentModel) throw new Error("The current runtime has no model with configurable reasoning effort.");
1191
+ if (!currentModel.supportedReasoningEfforts.some((candidate) => candidate.value === nextValue)) {
1192
+ const supported = currentModel.supportedReasoningEfforts.map((candidate) => candidate.value).join(", ") || "none";
1193
+ throw new Error(`Unsupported reasoning effort: ${nextValue}. Supported values: ${supported}.`);
1194
+ }
1195
+ await loaded.runtime.setReasoningEffort(record.localSessionId, nextValue);
1196
+ this.store.updateRuntimeSession(record.localSessionId, { reasoningEffort: nextValue });
1197
+ } else if (setting === "permissions" && nextValue) {
1198
+ if (nextValue !== "auto" && nextValue !== "confirm") {
1199
+ throw new Error("task permissions accepts auto or confirm.");
1200
+ }
1201
+ await loaded.runtime.setPermissionMode(record.localSessionId, nextValue);
1202
+ this.store.updateRuntimeSession(record.localSessionId, { permissionMode: nextValue });
1203
+ }
1204
+ const session = this.store.getSession(record.localSessionId) ?? record;
1205
+ const models = await loaded.runtime.listModels();
1206
+ const currentModel = models.find((candidate) => candidate.id === session.model)
1207
+ ?? models.find((candidate) => candidate.isDefault);
1208
+ const providers = loaded.runtime.listModelProviders
1209
+ ? await loaded.runtime.listModelProviders()
1210
+ : [];
1211
+ return {
1212
+ session,
1213
+ providers,
1214
+ models,
1215
+ reasoningOptions: currentModel?.supportedReasoningEfforts ?? [],
1216
+ permissionModes: ["auto", "confirm"],
1217
+ };
1218
+ }
1219
+
1220
+ async controlTaskGoal(
1221
+ localSessionId: string,
1222
+ action: "show" | "set" | "edit" | "pause" | "resume" | "clear",
1223
+ objective?: string,
1224
+ ): Promise<{ goal?: RuntimeGoal; cleared?: boolean }> {
1225
+ const record = this.requireControlSession(localSessionId);
1226
+ const loaded = await this.loadSession(record);
1227
+ if (loaded.runtime.kind !== "codex" || !loaded.runtime.getGoal || !loaded.runtime.setGoal || !loaded.runtime.clearGoal) {
1228
+ throw new Error("The current Agent does not support Goal mode.");
1229
+ }
1230
+ if (action === "show") return { goal: await loaded.runtime.getGoal(record.localSessionId) };
1231
+ if (action === "clear") return { cleared: await loaded.runtime.clearGoal(record.localSessionId) };
1232
+ const current = await loaded.runtime.getGoal(record.localSessionId);
1233
+ if (action === "pause" || action === "resume") {
1234
+ if (!current) throw new Error("The task has no Goal.");
1235
+ return { goal: await loaded.runtime.setGoal(record.localSessionId, { status: action === "pause" ? "paused" : "active" }) };
1236
+ }
1237
+ const nextObjective = objective?.trim();
1238
+ if (!nextObjective) throw new Error(`task goal ${action} requires an objective.`);
1239
+ validateGoalObjective(nextObjective);
1240
+ if (action === "set" && current && current.status !== "complete") {
1241
+ throw new Error("The task already has an unfinished Goal. Use task goal <task> edit <objective>, or clear it first.");
1242
+ }
1243
+ if (action === "edit" && !current) throw new Error("The task has no Goal to edit.");
1244
+ return {
1245
+ goal: await loaded.runtime.setGoal(record.localSessionId, {
1246
+ objective: nextObjective,
1247
+ status: action === "edit" ? current!.status : "active",
1248
+ ...(action === "edit" ? { tokenBudget: current!.tokenBudget } : {}),
1249
+ }),
1250
+ };
1251
+ }
1252
+
1253
+ controlListTaskTurns(localSessionId: string): {
1254
+ session: SessionRecord;
1255
+ turns: Array<{
1256
+ sequence: number;
1257
+ turnId: string;
1258
+ parentTurnId?: string;
1259
+ prompt?: string;
1260
+ startedAt?: number;
1261
+ completedAt?: number;
1262
+ current: boolean;
1263
+ }>;
1264
+ } {
1265
+ const session = this.requireControlSession(localSessionId);
1266
+ if (!session.remoteSessionId || !this.isCodexSession(session)) {
1267
+ throw new Error("The task is not an App Server task that can be reset.");
1268
+ }
1269
+ const contextKey = this.controlSessionContextKey(session);
1270
+ const rows = this.store.listCompletedTurnGraph(session.localSessionId, contextKey);
1271
+ const graph = buildTurnGraphRows(rows.map((turn) => ({ turnId: turn.turnId, parentTurnId: turn.parentTurnId })));
1272
+ return {
1273
+ session,
1274
+ turns: rows.flatMap((turn, index) => {
1275
+ const snapshot = turnViewSnapshot(turn.snapshot);
1276
+ if (!snapshot) return [];
1277
+ return [{
1278
+ sequence: graph[index]!.sequence,
1279
+ turnId: turn.turnId,
1280
+ parentTurnId: turn.parentTurnId,
1281
+ prompt: snapshot.prompt ?? snapshot.taskTitle,
1282
+ startedAt: snapshot.startedAt,
1283
+ completedAt: snapshot.completedAt,
1284
+ current: session.lastTurnId === turn.turnId && session.lastTurnStatus === "completed",
1285
+ }];
1286
+ }),
1287
+ };
1288
+ }
1289
+
1290
+ async controlResetTaskToTurn(localSessionId: string, turnId: string): Promise<SessionRecord> {
1291
+ const record = this.requireControlSession(localSessionId);
1292
+ const contextKey = this.controlSessionContextKey(record);
1293
+ const previous = this.sessionResets.get(record.localSessionId) ?? Promise.resolve();
1294
+ const reset = previous.catch(() => undefined).then(() =>
1295
+ this.performSessionReset(contextKey, record, turnId, false));
1296
+ this.sessionResets.set(record.localSessionId, reset);
1297
+ try {
1298
+ await reset;
1299
+ } finally {
1300
+ if (this.sessionResets.get(record.localSessionId) === reset) this.sessionResets.delete(record.localSessionId);
1301
+ }
1302
+ return this.store.getSession(record.localSessionId) ?? record;
1303
+ }
1304
+
1305
+ controlTaskMute(localSessionId: string, enabled?: boolean): { contextKey: string; enabled: boolean } {
1306
+ const record = this.requireControlSession(localSessionId);
1307
+ const contextKey = baseChatContextKey(this.controlSessionContextKey(record));
1308
+ const chat = this.store.getChatContext(contextKey);
1309
+ if (chat?.chatType !== "group") throw new Error("task mute is only available for group chats.");
1310
+ const next = enabled ?? Boolean(chat.requiresMention);
1311
+ if (enabled !== undefined) this.store.setChatRequiresMention(contextKey, enabled);
1312
+ return { contextKey, enabled: next };
1313
+ }
1314
+
1315
+ async controlRunTaskShell(localSessionId: string, command: string): Promise<ShellCommandResult & {
1316
+ cwd: string;
1317
+ command: string;
1318
+ }> {
1319
+ const record = this.requireControlSession(localSessionId);
1320
+ const normalized = command.trim();
1321
+ if (!normalized) throw new Error("task shell requires a command.");
1322
+ return { ...await this.shellCommandExecutor(normalized, record.cwd), cwd: record.cwd, command: normalized };
1323
+ }
1324
+
1325
+ async controlListTaskDirectory(
1326
+ localSessionId: string,
1327
+ requestedDirectory?: string,
1328
+ requestedPage = 0,
1329
+ ): Promise<{
1330
+ directory: string;
1331
+ parentDirectory?: string;
1332
+ page: number;
1333
+ totalPages: number;
1334
+ totalEntries: number;
1335
+ entries: Array<{
1336
+ name: string;
1337
+ path: string;
1338
+ kind: "directory" | "image" | "binary" | "file" | "drive";
1339
+ }>;
1340
+ }> {
1341
+ const record = this.requireControlSession(localSessionId);
1342
+ if (requestedDirectory === WINDOWS_DRIVES_DIRECTORY) {
1343
+ const drives = await this.windowsDriveLister();
1344
+ const totalPages = Math.max(1, Math.ceil(drives.length / DIRECTORY_PAGE_SIZE));
1345
+ const page = Math.min(Math.max(0, Math.trunc(requestedPage)), totalPages - 1);
1346
+ return {
1347
+ directory: WINDOWS_DRIVES_DIRECTORY,
1348
+ page,
1349
+ totalPages,
1350
+ totalEntries: drives.length,
1351
+ entries: drives.slice(page * DIRECTORY_PAGE_SIZE, (page + 1) * DIRECTORY_PAGE_SIZE).map((drive) => ({
1352
+ name: windowsDriveDisplayName(drive),
1353
+ path: drive.root,
1354
+ kind: "drive" as const,
1355
+ })),
1356
+ };
1357
+ }
1358
+ const directory = requestedDirectory === undefined
1359
+ ? path.resolve(record.cwd)
1360
+ : resolveUserPath(requestedDirectory, record.cwd);
1361
+ await this.assertBrowsableDirectory(directory);
1362
+ const entries = (await fs.readdir(directory, { withFileTypes: true }))
1363
+ .map((entry) => ({
1364
+ name: entry.name,
1365
+ path: path.join(directory, entry.name),
1366
+ kind: directoryBrowserEntryKind(entry),
1367
+ }))
1368
+ .sort((left, right) => Number(right.kind === "directory") - Number(left.kind === "directory")
1369
+ || left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: "base" }));
1370
+ const totalPages = Math.max(1, Math.ceil(entries.length / DIRECTORY_PAGE_SIZE));
1371
+ const page = Math.min(Math.max(0, Math.trunc(requestedPage)), totalPages - 1);
1372
+ const parentDirectory = isWindowsDriveRoot(directory) ? WINDOWS_DRIVES_DIRECTORY : path.dirname(directory);
1373
+ return {
1374
+ directory,
1375
+ ...(parentDirectory !== directory ? { parentDirectory } : {}),
1376
+ page,
1377
+ totalPages,
1378
+ totalEntries: entries.length,
1379
+ entries: entries.slice(page * DIRECTORY_PAGE_SIZE, (page + 1) * DIRECTORY_PAGE_SIZE),
1380
+ };
1381
+ }
1382
+
1383
+ async controlSendTaskFile(localSessionId: string, requestedFilePath: string): Promise<string | undefined> {
1384
+ const record = this.requireControlSession(localSessionId);
1385
+ const filePath = resolveUserPath(requestedFilePath, record.cwd);
1386
+ await this.assertSendableFile(filePath);
1387
+ const contextKey = this.controlSessionContextKey(record);
1388
+ const replyTarget = this.controlSessionReplyTarget(record);
1389
+ return this.outbound.withReplyTarget(contextKey, replyTarget, () => this.outbound.sendFile(contextKey, filePath));
1390
+ }
1391
+
987
1392
  private cardActionContextKey(action: CardAction): string {
988
1393
  const explicit = typeof action.value.contextKey === "string" ? action.value.contextKey : undefined;
989
1394
  if (explicit && baseChatContextKey(explicit) === baseChatContextKey(action.contextKey)) return explicit;
@@ -1079,6 +1484,15 @@ export class ProxySessionController {
1079
1484
  if (command.agent) await this.setDefaultAgent(contextKey, command.agent);
1080
1485
  else await this.openAgentSettings(contextKey);
1081
1486
  return;
1487
+ case "archive": {
1488
+ const sessionId = command.sessionId ?? context.currentSessionId;
1489
+ if (!sessionId) throw new Error("当前会话没有可归档的任务。请使用 /sessions 查找任务。");
1490
+ await this.archiveSessionReference(contextKey, sessionId, {
1491
+ announce: true,
1492
+ source: "command",
1493
+ });
1494
+ return;
1495
+ }
1082
1496
  case "status":
1083
1497
  await this.status(contextKey, command.sessionId);
1084
1498
  return;
@@ -3107,16 +3521,25 @@ export class ProxySessionController {
3107
3521
  if (current.localSessionId !== sessionId) {
3108
3522
  throw new Error("这张思考卡片不属于当前任务。请先切换回对应任务,再点击 Reset。");
3109
3523
  }
3524
+ await this.performSessionReset(contextKey, current, turnId, true);
3525
+ }
3526
+
3527
+ private async performSessionReset(
3528
+ contextKey: string,
3529
+ current: SessionRecord,
3530
+ turnId: string,
3531
+ announce: boolean,
3532
+ ): Promise<void> {
3110
3533
  if (!current.remoteSessionId || !this.isCodexSession(current)) {
3111
3534
  throw new Error("当前任务不是可 Reset 的 App Server 任务。");
3112
3535
  }
3113
3536
 
3114
3537
  const snapshot = turnViewSnapshot(this.store.getTurnSnapshot(turnId));
3115
- if (!snapshot || snapshot.sessionId !== sessionId || snapshot.status !== "completed") {
3538
+ if (!snapshot || snapshot.sessionId !== current.localSessionId || snapshot.status !== "completed") {
3116
3539
  throw new Error("只能将当前任务 Reset 到已成功完成的轮次。");
3117
3540
  }
3118
3541
  if (current.lastTurnId === turnId && current.lastTurnStatus === "completed") {
3119
- await this.outbound.sendText(contextKey, "当前任务已经处于本轮完成后的对话状态,无需 Reset。");
3542
+ if (announce) await this.outbound.sendText(contextKey, "当前任务已经处于本轮完成后的对话状态,无需 Reset。");
3120
3543
  return;
3121
3544
  }
3122
3545
 
@@ -3133,7 +3556,7 @@ export class ProxySessionController {
3133
3556
  }
3134
3557
 
3135
3558
  const origin = this.store.getTurnRuntimeOrigin(turnId);
3136
- if (origin && (origin.localSessionId !== sessionId || origin.agentName !== current.agentName)) {
3559
+ if (origin && (origin.localSessionId !== current.localSessionId || origin.agentName !== current.agentName)) {
3137
3560
  throw new Error("这张思考卡片与当前任务不匹配,已拒绝 Reset。");
3138
3561
  }
3139
3562
  const sourceRemoteSessionId = origin?.remoteSessionId ?? current.remoteSessionId;
@@ -3174,16 +3597,18 @@ export class ProxySessionController {
3174
3597
  100,
3175
3598
  ) || "未记录对话内容";
3176
3599
  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
- );
3600
+ if (announce) {
3601
+ await this.outbound.sendText(
3602
+ contextKey,
3603
+ [
3604
+ "已将当前任务重置到:",
3605
+ targetSummary,
3606
+ `完成时间:${targetTime === undefined ? "未知" : formatResetTurnTime(targetTime)}`,
3607
+ `Turn ID:${turnId}`,
3608
+ "后续对话将从该轮完成后的状态继续;本地文件没有回退。",
3609
+ ].join("\n"),
3610
+ );
3611
+ }
3187
3612
  }
3188
3613
 
3189
3614
  private async resolveApproval(action: CardAction): Promise<void> {
@@ -4112,6 +4537,16 @@ export class ProxySessionController {
4112
4537
  contextKey,
4113
4538
  },
4114
4539
  });
4540
+ actions.push({
4541
+ text: "Archive",
4542
+ value: {
4543
+ action: "session_archive",
4544
+ sessionId: entry.reference,
4545
+ ...(searchTerm ? { searchTerm } : {}),
4546
+ page: String(page),
4547
+ contextKey,
4548
+ },
4549
+ });
4115
4550
  const title = truncateText(entry.title.replace(/\s+/gu, " ").trim() || "未命名任务", 56);
4116
4551
  return {
4117
4552
  project,
@@ -4153,7 +4588,7 @@ export class ProxySessionController {
4153
4588
  `第 ${page + 1} 页 · 每页 ${SESSION_PAGE_SIZE} 个任务${hasNext ? "" : ` · 当前共 ${entries.length} 个任务`}`,
4154
4589
  "",
4155
4590
  "> 项目行:**New** 新建任务,**NewGroup** 新建群。",
4156
- "> 任务详情:**Switch** 切换,**Stop** 停止,**Fork** / **ForkGroup** 创建分支,**Status** 查看状态。",
4591
+ "> 任务详情:**Switch** 切换,**Stop** 停止,**Fork** / **ForkGroup** 创建分支,**Status** 查看状态,**Archive** 归档。",
4157
4592
  ],
4158
4593
  [
4159
4594
  ...(hasPrevious ? [{
@@ -4342,6 +4777,67 @@ export class ProxySessionController {
4342
4777
  await this.outbound.sendText(contextKey, `已向 Agent 发送 Interrupt 请求:${turnId}`);
4343
4778
  }
4344
4779
 
4780
+ private async archiveSessionReference(
4781
+ contextKey: string,
4782
+ reference: string,
4783
+ options: { announce: boolean; source: "cli" | "command" | "sessions_card" },
4784
+ ): Promise<{ localSessionId: string; remoteSessionId: string; title: string }> {
4785
+ const taskId = this.resolveSessionReference(contextKey, reference);
4786
+ const direct = this.store.getSession(taskId);
4787
+ if (direct?.status === "closed") throw new Error(`任务已经归档:${direct.title ?? taskId}`);
4788
+ const local = direct ?? this.findStoredSessionByReference(taskId);
4789
+ if (local && (!local.remoteSessionId || !this.isCodexSession(local))) {
4790
+ throw new Error("当前 Agent 不支持归档任务。");
4791
+ }
4792
+ if (local && (
4793
+ local.status === "running"
4794
+ || Boolean(this.runtimes.forAgent(local.agentName).getSession(local.localSessionId)?.activeTurnId)
4795
+ )) {
4796
+ throw new Error("任务正在执行,无法归档。请先停止任务或等待本轮完成。");
4797
+ }
4798
+ if (local && this.store.countQueuedPrompts(local.localSessionId) > 0) {
4799
+ throw new Error("任务仍有排队消息,无法归档。请先处理或取消排队消息。");
4800
+ }
4801
+
4802
+ const remoteReference = local
4803
+ ? remoteSessionReference(local.agentName, local.remoteSessionId!)
4804
+ : taskId;
4805
+ const { agentName, runtime, remote } = await this.resolveRemoteCodexSession(remoteReference);
4806
+ if (isRemoteSessionActive(remote)) {
4807
+ throw new Error("任务正在执行,无法归档。请先停止任务或等待本轮完成。");
4808
+ }
4809
+ if (!runtime.archiveRemoteSession) {
4810
+ throw new Error(`Agent ${agentName} 不支持归档任务。`);
4811
+ }
4812
+
4813
+ const wasCurrent = Boolean(local && this.store.getUserContext(contextKey)?.currentSessionId === local.localSessionId);
4814
+ await runtime.archiveRemoteSession(remote.id);
4815
+ const title = local?.title ?? remote.title ?? remote.preview ?? remote.id;
4816
+ if (local) {
4817
+ this.store.archiveSession(local.localSessionId);
4818
+ this.outbound.unregisterSession(local.localSessionId);
4819
+ }
4820
+ this.store.audit(contextKey, "session_archived", {
4821
+ ...(local ? { localSessionId: local.localSessionId } : {}),
4822
+ remoteSessionId: remote.id,
4823
+ agentName,
4824
+ source: options.source,
4825
+ });
4826
+ if (options.announce) {
4827
+ await this.outbound.sendText(
4828
+ contextKey,
4829
+ wasCurrent
4830
+ ? `已归档任务:${title}\n当前会话已没有绑定任务,直接发送消息即可创建新任务。`
4831
+ : `已归档任务:${title}`,
4832
+ );
4833
+ }
4834
+ return {
4835
+ localSessionId: local?.localSessionId ?? "",
4836
+ remoteSessionId: remote.id,
4837
+ title,
4838
+ };
4839
+ }
4840
+
4345
4841
  private resolveSessionReference(contextKey: string, reference?: string): string {
4346
4842
  if (reference === undefined) {
4347
4843
  const previous = this.store.getOrCreateUserContext(contextKey, this.config.defaults.agent!).previousSessionId;
@@ -4687,6 +5183,27 @@ export class ProxySessionController {
4687
5183
  );
4688
5184
  }
4689
5185
 
5186
+ private requireControlSession(localSessionId: string): SessionRecord {
5187
+ const record = this.store.getSession(localSessionId);
5188
+ if (!record || record.status === "closed") throw new Error(`Task not found: ${localSessionId}`);
5189
+ return record;
5190
+ }
5191
+
5192
+ private controlSessionContextKey(record: SessionRecord): string {
5193
+ return this.outbound.getSessionContextKey(record.localSessionId)
5194
+ ?? (record.lastTurnId ? this.store.getTurnContextKey(record.lastTurnId) : undefined)
5195
+ ?? record.contextKey;
5196
+ }
5197
+
5198
+ private controlSessionReplyTarget(record: SessionRecord): MessageReplyTarget | undefined {
5199
+ const routed = this.outbound.getSessionReplyTarget(record.localSessionId);
5200
+ if (routed) return routed;
5201
+ const snapshot = record.lastTurnId
5202
+ ? turnViewSnapshot(this.store.getTurnSnapshot(record.lastTurnId))
5203
+ : undefined;
5204
+ return snapshot?.replyTarget;
5205
+ }
5206
+
4690
5207
  private ensureAgent(agentName: string) {
4691
5208
  const agent = this.config.agents[agentName];
4692
5209
  if (!agent) throw new Error(`未知 agent:${agentName}`);
@@ -5246,7 +5763,7 @@ function isBotOwnedActiveTurn(record: SessionRecord, remote: RemoteSessionSummar
5246
5763
  }
5247
5764
 
5248
5765
  function isQueueIndependentCommand(command: Command): boolean {
5249
- if (["stop", "status", "restart", "mute", "help", "sessions", "dir", "goal", "nosteer"].includes(command.type)) return true;
5766
+ if (["archive", "stop", "status", "restart", "mute", "help", "sessions", "dir", "goal", "nosteer"].includes(command.type)) return true;
5250
5767
  if (command.type === "agent") return command.agent === undefined;
5251
5768
  if (["model", "provider", "thinking", "permissions"].includes(command.type)) return true;
5252
5769
  return false;
@@ -221,6 +221,7 @@ export interface AgentRuntime {
221
221
  steerTurn(sessionId: string, turnId: string, prompt: RuntimePrompt): Promise<void>;
222
222
  cancelTurn(sessionId: string, turnId: string): Promise<void>;
223
223
  interruptRemoteTurn?(remoteSessionId: string, turnId: string): Promise<void>;
224
+ archiveRemoteSession?(remoteSessionId: string): Promise<void>;
224
225
  closeSession(sessionId: string): Promise<void>;
225
226
  setTitle?(sessionId: string, title: string): Promise<void>;
226
227
  getGoal?(sessionId: string): Promise<RuntimeGoal | undefined>;
@@ -743,6 +743,25 @@ export class StateStore {
743
743
  );
744
744
  }
745
745
 
746
+ archiveSession(localSessionId: string): void {
747
+ const now = new Date().toISOString();
748
+ const archive = this.db.transaction(() => {
749
+ this.db.prepare(`
750
+ UPDATE sessions
751
+ SET status = 'closed', updated_at = ?
752
+ WHERE local_session_id = ?
753
+ `).run(now, localSessionId);
754
+ this.db.prepare(`
755
+ UPDATE user_contexts
756
+ SET current_session_id = CASE WHEN current_session_id = ? THEN NULL ELSE current_session_id END,
757
+ previous_session_id = CASE WHEN previous_session_id = ? THEN NULL ELSE previous_session_id END,
758
+ updated_at = ?
759
+ WHERE current_session_id = ? OR previous_session_id = ?
760
+ `).run(localSessionId, localSessionId, now, localSessionId, localSessionId);
761
+ });
762
+ archive();
763
+ }
764
+
746
765
  updateRuntimeSession(
747
766
  localSessionId: string,
748
767
  patch: Partial<