@keyou007/agent-bot 0.1.19-alpha.0 → 0.1.19

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 (42) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +3 -0
  3. package/README.zh.md +3 -0
  4. package/dist/cli/controlProtocol.js.map +1 -1
  5. package/dist/cli/help.js +2 -0
  6. package/dist/cli/help.js.map +1 -1
  7. package/dist/cli.js +20 -0
  8. package/dist/cli.js.map +1 -1
  9. package/dist/codex/CodexProcessManager.js +48 -0
  10. package/dist/codex/CodexProcessManager.js.map +1 -1
  11. package/dist/codex/CodexRuntime.js +94 -3
  12. package/dist/codex/CodexRuntime.js.map +1 -1
  13. package/dist/commands/CommandRouter.js +5 -0
  14. package/dist/commands/CommandRouter.js.map +1 -1
  15. package/dist/feishu/CardRenderer.js +83 -0
  16. package/dist/feishu/CardRenderer.js.map +1 -1
  17. package/dist/feishu/MergedForwardMessage.js +6 -1
  18. package/dist/feishu/MergedForwardMessage.js.map +1 -1
  19. package/dist/index.js +2 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/proxy/ProxySessionController.js +455 -7
  22. package/dist/proxy/ProxySessionController.js.map +1 -1
  23. package/dist/state/StateStore.js +10 -0
  24. package/dist/state/StateStore.js.map +1 -1
  25. package/docs/technical-reference.md +2 -2
  26. package/docs/technical-reference.zh.md +2 -2
  27. package/npm-shrinkwrap.json +2 -2
  28. package/package.json +1 -1
  29. package/skills/agent-bot/SKILL.md +3 -0
  30. package/src/cli/controlProtocol.ts +8 -0
  31. package/src/cli/help.ts +2 -0
  32. package/src/cli.ts +33 -0
  33. package/src/codex/CodexProcessManager.ts +43 -0
  34. package/src/codex/CodexRuntime.ts +112 -3
  35. package/src/commands/CommandRouter.ts +4 -0
  36. package/src/commands/commandTypes.ts +1 -0
  37. package/src/feishu/CardRenderer.ts +96 -0
  38. package/src/feishu/MergedForwardMessage.ts +5 -1
  39. package/src/index.ts +2 -0
  40. package/src/proxy/ProxySessionController.ts +553 -9
  41. package/src/runtime/types.ts +7 -0
  42. package/src/state/StateStore.ts +11 -0
@@ -17,6 +17,7 @@ import type {
17
17
  RuntimeEvent,
18
18
  RuntimeExecutionSettings,
19
19
  RuntimePrompt,
20
+ RuntimeReleaseResult,
20
21
  RuntimeSession,
21
22
  RuntimeSessionMetadata,
22
23
  } from "../runtime/types.js";
@@ -41,6 +42,9 @@ const SESSION_REQUEST_TIMEOUT_MS = 60_000;
41
42
  const CONTROL_REQUEST_TIMEOUT_MS = 10_000;
42
43
  const SYNC_REQUEST_TIMEOUT_MS = 5_000;
43
44
  const THREAD_TURN_PAGE_SIZE = 100;
45
+ const FORK_SOURCE_TURN_PAGE_SIZE = 20;
46
+ const FORK_SOURCE_REQUEST_TIMEOUT_MS = 15_000;
47
+ const USER_RESUMABLE_THREAD_SOURCE_KINDS = ["cli", "vscode", "exec", "appServer"] as const;
44
48
  // A timed-out fork keeps running in App Server and can create an orphan thread.
45
49
  // Wait for its response; connection closure still rejects the request.
46
50
  const FORK_REQUEST_TIMEOUT_MS = 0;
@@ -61,6 +65,7 @@ export interface AppServerClientProvider {
61
65
  getProcessInfo?(): AgentProcessInfo;
62
66
  getCodexHome?(): string;
63
67
  onDisconnect?(listener: (error: Error) => void): () => void;
68
+ release?(): Promise<void>;
64
69
  close(): void;
65
70
  }
66
71
 
@@ -91,6 +96,7 @@ export class CodexRuntime implements AgentRuntime {
91
96
  private readonly localActivityDetector?: CodexLocalActivityDetector;
92
97
  private readonly codexHome?: string;
93
98
  private threadListSortKey: ThreadListSortKey = "recency_at";
99
+ private releaseInFlight?: Promise<RuntimeReleaseResult>;
94
100
 
95
101
  constructor(
96
102
  private readonly provider: AppServerClientProvider,
@@ -290,6 +296,7 @@ export class CodexRuntime implements AgentRuntime {
290
296
  limit: input.limit ?? 20,
291
297
  sortKey,
292
298
  sortDirection: "desc",
299
+ sourceKinds: USER_RESUMABLE_THREAD_SOURCE_KINDS,
293
300
  archived: false,
294
301
  searchTerm: input.searchTerm,
295
302
  },
@@ -300,7 +307,37 @@ export class CodexRuntime implements AgentRuntime {
300
307
  async readRemoteSession(remoteSessionId: string): Promise<RemoteSessionSummary> {
301
308
  const client = await this.client();
302
309
  const response = await this.readThreadWithTurns(client, remoteSessionId);
303
- const summary = remoteSessionSummary(response.thread);
310
+ return this.decorateRemoteSession(remoteSessionId, remoteSessionSummary(response.thread));
311
+ }
312
+
313
+ async readRemoteForkSource(remoteSessionId: string): Promise<RemoteSessionSummary> {
314
+ const client = await this.client();
315
+ const metadata = await client.request<ThreadReadResponse>(
316
+ "thread/read",
317
+ { threadId: remoteSessionId, includeTurns: false },
318
+ FORK_SOURCE_REQUEST_TIMEOUT_MS,
319
+ );
320
+ let turns: CodexTurnSnapshot[];
321
+ try {
322
+ turns = await this.listRecentThreadTurnsThroughCompleted(client, remoteSessionId);
323
+ } catch (error) {
324
+ if (!isUnsupportedThreadTurnsListError(error)) throw error;
325
+ this.logger.warn(
326
+ { error, remoteSessionId },
327
+ "App Server does not support lightweight Fork source reads; falling back to complete thread history.",
328
+ );
329
+ return this.readRemoteSession(remoteSessionId);
330
+ }
331
+ return this.decorateRemoteSession(remoteSessionId, remoteSessionSummary({
332
+ ...metadata.thread,
333
+ turns,
334
+ }));
335
+ }
336
+
337
+ private async decorateRemoteSession(
338
+ remoteSessionId: string,
339
+ summary: RemoteSessionSummary,
340
+ ): Promise<RemoteSessionSummary> {
304
341
  const activeThreads = await this.localActivityDetector?.activeThreads([remoteSessionId]);
305
342
  const activeSummary = activeThreads ? markLocallyDetectedActive(summary, activeThreads) : summary;
306
343
  const settings = await this.localActivityDetector?.threadSettings([remoteSessionId]);
@@ -310,6 +347,46 @@ export class CodexRuntime implements AgentRuntime {
310
347
  };
311
348
  }
312
349
 
350
+ private async listRecentThreadTurnsThroughCompleted(
351
+ client: AppServerClient,
352
+ remoteSessionId: string,
353
+ ): Promise<CodexTurnSnapshot[]> {
354
+ const turns: CodexTurnSnapshot[] = [];
355
+ const seenCursors = new Set<string>();
356
+ let cursor: string | undefined;
357
+ do {
358
+ const response = await client.request<ThreadTurnsListResponse>(
359
+ "thread/turns/list",
360
+ {
361
+ threadId: remoteSessionId,
362
+ ...(cursor ? { cursor } : {}),
363
+ limit: FORK_SOURCE_TURN_PAGE_SIZE,
364
+ sortDirection: "desc",
365
+ itemsView: "summary",
366
+ },
367
+ FORK_SOURCE_REQUEST_TIMEOUT_MS,
368
+ );
369
+ turns.push(...response.data);
370
+ const completedIndex = turns.findIndex((turn) => turn.status === "completed");
371
+ if (completedIndex >= 0) {
372
+ turns.splice(completedIndex + 1);
373
+ break;
374
+ }
375
+ const nextCursor = response.nextCursor ?? undefined;
376
+ if (!nextCursor) break;
377
+ if (seenCursors.has(nextCursor)) {
378
+ this.logger.warn(
379
+ { remoteSessionId, cursor: nextCursor },
380
+ "App Server repeated a Fork source Turn cursor; stopping pagination.",
381
+ );
382
+ break;
383
+ }
384
+ seenCursors.add(nextCursor);
385
+ cursor = nextCursor;
386
+ } while (cursor);
387
+ return turns.reverse();
388
+ }
389
+
313
390
  private async readThreadWithTurns(
314
391
  client: AppServerClient,
315
392
  remoteSessionId: string,
@@ -602,6 +679,29 @@ export class CodexRuntime implements AgentRuntime {
602
679
  return () => this.listeners.delete(listener);
603
680
  }
604
681
 
682
+ async release(options: { force?: boolean } = {}): Promise<RuntimeReleaseResult> {
683
+ if (this.releaseInFlight) return this.releaseInFlight;
684
+ const activeSessionIds = [...this.sessions.values()]
685
+ .filter((session) => Boolean(session.activeTurnId))
686
+ .map((session) => session.localSessionId);
687
+ if (activeSessionIds.length > 0 && options.force !== true) {
688
+ return { status: "busy", activeSessionIds };
689
+ }
690
+
691
+ const operation = (async (): Promise<RuntimeReleaseResult> => {
692
+ this.handleDisconnect(new Error("App Server released by user."), true);
693
+ if (this.provider.release) await this.provider.release();
694
+ else this.provider.close();
695
+ return { status: "released" };
696
+ })();
697
+ this.releaseInFlight = operation;
698
+ try {
699
+ return await operation;
700
+ } finally {
701
+ if (this.releaseInFlight === operation) this.releaseInFlight = undefined;
702
+ }
703
+ }
704
+
605
705
  close(): void {
606
706
  this.unsubscribe?.();
607
707
  this.unsubscribeDisconnect?.();
@@ -613,6 +713,7 @@ export class CodexRuntime implements AgentRuntime {
613
713
  }
614
714
 
615
715
  private async client(): Promise<AppServerClient> {
716
+ if (this.releaseInFlight) await this.releaseInFlight;
616
717
  const client = await this.provider.getClient();
617
718
  if (client !== this.attachedClient) this.attachClient(client);
618
719
  return client;
@@ -1012,7 +1113,7 @@ export class CodexRuntime implements AgentRuntime {
1012
1113
  for (const listener of this.listeners) listener(event);
1013
1114
  }
1014
1115
 
1015
- private handleDisconnect(error: Error): void {
1116
+ private handleDisconnect(error: Error, intentionalRelease = false): void {
1016
1117
  this.unsubscribe?.();
1017
1118
  this.unsubscribe = undefined;
1018
1119
  this.attachedClient = undefined;
@@ -1027,7 +1128,9 @@ export class CodexRuntime implements AgentRuntime {
1027
1128
  type: "turn_failed",
1028
1129
  sessionId: session.localSessionId,
1029
1130
  turnId,
1030
- message: `App Server disconnected: ${error.message}`,
1131
+ message: intentionalRelease
1132
+ ? "Task interrupted because Agent Bot released the App Server."
1133
+ : `App Server disconnected: ${error.message}`,
1031
1134
  });
1032
1135
  }
1033
1136
  for (const [requestId, pending] of this.approvals) {
@@ -1198,6 +1301,12 @@ function isPaginatedThreadReadError(error: unknown): boolean {
1198
1301
  return /paginated threads?[^\n]*do(?:es)? not support[^\n]*thread\/read[^\n]*includeTurns\s*=\s*true/i.test(details);
1199
1302
  }
1200
1303
 
1304
+ function isUnsupportedThreadTurnsListError(error: unknown): boolean {
1305
+ if (!(error instanceof AppServerRequestError) || error.method !== "thread/turns/list") return false;
1306
+ return error.code === -32601
1307
+ || /method not found|unknown method|unsupported|not supported/i.test(appServerErrorDetails(error));
1308
+ }
1309
+
1201
1310
  function isUnmaterializedThreadReadError(error: unknown): boolean {
1202
1311
  const details = appServerErrorDetails(error);
1203
1312
  return /thread[^\n]*(?:not materialized|not loaded)[^\n]*includeTurns[^\n]*(?:unavailable|before first user message)/i
@@ -18,6 +18,7 @@ export const COMMAND_NAMES = [
18
18
  "permissions",
19
19
  "provider",
20
20
  "queue",
21
+ "release",
21
22
  "restart",
22
23
  "sessions",
23
24
  "status",
@@ -114,6 +115,9 @@ export class CommandRouter {
114
115
  if (args.length === 0) return { type: "restart" };
115
116
  if (args.length === 1 && args[0] === "--force") return { type: "restart", force: true };
116
117
  throw new Error("/restart 只接受一个可选的 --force 参数。");
118
+ case "release":
119
+ if (args.length > 0) throw new Error("/release 不接受参数。");
120
+ return { type: "release" };
117
121
  case "mute": {
118
122
  if (args.length === 0 || (args.length === 1 && args[0]!.toLowerCase() === "on")) {
119
123
  return { type: "mute", enabled: true };
@@ -19,6 +19,7 @@ export type Command =
19
19
  | { type: "goal"; action: "set" | "edit"; objective: string }
20
20
  | { type: "goal"; action: "pause" | "resume" | "clear" }
21
21
  | { type: "restart"; force?: boolean }
22
+ | { type: "release" }
22
23
  | { type: "mute"; enabled: boolean }
23
24
  | { type: "turns" }
24
25
  | { type: "model" }
@@ -275,6 +275,17 @@ export interface ThreadWriterConflictCardView {
275
275
  };
276
276
  }
277
277
 
278
+ export interface AppServerReleaseCardView {
279
+ status: "waiting" | "releasing" | "released" | "cancelled" | "failed";
280
+ contextKey: string;
281
+ scheduleId: number;
282
+ agentName: string;
283
+ blockingTaskCount?: number;
284
+ releaseInSeconds?: number;
285
+ forced?: boolean;
286
+ error?: string;
287
+ }
288
+
278
289
  export class CardRenderer {
279
290
  private readonly thinkingCardLayout: ThinkingCardLayout;
280
291
 
@@ -371,6 +382,91 @@ export class CardRenderer {
371
382
  );
372
383
  }
373
384
 
385
+ renderAppServerRelease(view: AppServerReleaseCardView): Record<string, unknown> {
386
+ const agent = inlineCode(view.agentName);
387
+ if (view.status === "released") {
388
+ return sectionCard("任务已释放", [markdown([
389
+ `**Agent**:${agent}`,
390
+ view.forced ? "执行中的任务已中断。" : undefined,
391
+ "> 请在 Codex Desktop 点击 **Retry**。",
392
+ ].filter((line): line is string => Boolean(line)).join("\n"))], "green");
393
+ }
394
+ if (view.status === "cancelled") {
395
+ return sectionCard("已取消释放", [markdown(`**Agent**:${agent}\n> Agent Bot 将继续保持当前任务连接。`)], "grey");
396
+ }
397
+ if (view.status === "failed") {
398
+ return sectionCard("释放失败", [markdown([
399
+ `**Agent**:${agent}`,
400
+ view.error ? truncateText(view.error, 500) : "无法释放 App Server。",
401
+ ].join("\n"))], "red");
402
+ }
403
+ if (view.status === "releasing") {
404
+ return sectionCard("正在释放", [markdown(`**Agent**:${agent}\n正在停止 Agent Bot 的 App Server。`)], "blue");
405
+ }
406
+
407
+ const blockingTaskCount = Math.max(0, view.blockingTaskCount ?? 0);
408
+ const releaseInSeconds = Math.max(1, view.releaseInSeconds ?? 1);
409
+ return sectionCard("等待释放", [
410
+ markdown([
411
+ `**Agent**:${agent}`,
412
+ blockingTaskCount > 0
413
+ ? `**等待任务**:${blockingTaskCount}`
414
+ : `**自动释放**:${releaseInSeconds} 秒后`,
415
+ blockingTaskCount > 0
416
+ ? "> 任务结束后自动释放;立即释放会中断执行。"
417
+ : "> 可立即释放或取消。",
418
+ ].join("\n")),
419
+ {
420
+ tag: "column_set",
421
+ flex_mode: "none",
422
+ horizontal_spacing: "8px",
423
+ vertical_align: "center",
424
+ columns: [
425
+ {
426
+ tag: "column",
427
+ width: "auto",
428
+ vertical_align: "center",
429
+ elements: [{
430
+ tag: "button",
431
+ text: { tag: "plain_text", content: "Release Now" },
432
+ type: "danger",
433
+ size: "small",
434
+ behaviors: [{
435
+ type: "callback",
436
+ value: {
437
+ action: "app_server_release_now",
438
+ contextKey: view.contextKey,
439
+ scheduleId: view.scheduleId,
440
+ agentName: view.agentName,
441
+ },
442
+ }],
443
+ }],
444
+ },
445
+ {
446
+ tag: "column",
447
+ width: "auto",
448
+ vertical_align: "center",
449
+ elements: [{
450
+ tag: "button",
451
+ text: { tag: "plain_text", content: "Cancel" },
452
+ type: "default",
453
+ size: "small",
454
+ behaviors: [{
455
+ type: "callback",
456
+ value: {
457
+ action: "app_server_release_cancel",
458
+ contextKey: view.contextKey,
459
+ scheduleId: view.scheduleId,
460
+ agentName: view.agentName,
461
+ },
462
+ }],
463
+ }],
464
+ },
465
+ ],
466
+ },
467
+ ], "orange");
468
+ }
469
+
374
470
  renderShellCommandCard(view: ShellCommandCardView): Record<string, unknown> {
375
471
  const rawOutput = normalizeTerminalOutput(view.output).trimEnd();
376
472
  const displayOutput = truncateMiddle(
@@ -273,8 +273,12 @@ function renderPost(
273
273
  for (const rawElement of row) {
274
274
  if (!isRecord(rawElement)) continue;
275
275
  const tag = textValue(rawElement.tag);
276
- if (tag === "text" || tag === "a") {
276
+ if (tag === "text") {
277
277
  rowText += textValue(rawElement.text);
278
+ } else if (tag === "a") {
279
+ const text = textValue(rawElement.text);
280
+ const href = textValue(rawElement.href);
281
+ rowText += href && href !== text ? `${text || href} (${href})` : text || href;
278
282
  } else if (tag === "at") {
279
283
  rowText += renderMention(rawElement, mentions);
280
284
  } else if (tag === "img") {
package/src/index.ts CHANGED
@@ -432,6 +432,8 @@ async function handleControlRequest(request: ControlRequest): Promise<ControlRes
432
432
  return { ok: true, data: await controller.controlGetTaskStatus(request.localSessionId) };
433
433
  case "task_stop":
434
434
  return { ok: true, message: await controller.controlStopTask(request.localSessionId) };
435
+ case "task_release":
436
+ return { ok: true, data: await controller.controlReleaseTask(request.localSessionId) };
435
437
  case "task_archive":
436
438
  return { ok: true, data: await controller.controlArchiveTask(request.localSessionId) };
437
439
  case "task_dismiss":