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

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.
@@ -261,7 +261,7 @@ Starting a Feishu thread from a mapped user message, progress card, or final res
261
261
 
262
262
  `/forkgroup` has thread-aware source selection. An unbound thread, or a bound thread task with no completed turn of its own, forks directly from the thread's original anchor turn without creating an intermediate thread task. Once the thread task has completed a turn, `/forkgroup` uses its latest locally persisted completed turn. A newer active turn does not block the command and is excluded from the fork point.
263
263
 
264
- Agent Bot sends the experimental `excludeTurns: true` field on every `thread/fork` request. This suppresses populated `thread.turns` in the response without changing the history copied into the fork. The App Server connection enables `experimentalApi`; no user-facing command option is required. If an older App Server explicitly rejects `excludeTurns` as unknown, unsupported, or unavailable without experimental support, Agent Bot retries once without that field. Timeouts, disconnects, and unrelated fork errors are never retried because the first request may already have created a branch.
264
+ Before forking a task that has never been bound to Agent Bot, Agent Bot reads metadata without Turns and pages backward through the lightweight `summary` Turn view only until it finds the latest completed Turn. It does not load the task's complete tool history as a Fork prerequisite. Agent Bot then sends the experimental `excludeTurns: true` field on every `thread/fork` request. This suppresses populated `thread.turns` in the response without changing the history copied into the fork. The App Server connection enables `experimentalApi`; no user-facing command option is required. If an older App Server explicitly rejects the lightweight Turn-list method or the `excludeTurns` field, Agent Bot falls back to the compatible complete-history read or Fork request. Timeouts, disconnects, and unrelated Fork errors are never retried because a timed-out Fork request may already have created a branch.
265
265
 
266
266
  The new group's welcome message reports the persisted fork settings: Provider, model, reasoning effort, and permission type. Permission type is rendered as automatic execution or confirmation before execution.
267
267
 
@@ -261,7 +261,7 @@ AGENT_BOT_LARK_USER_OPEN_ID=<已保存的授权用户 open_id,可用时提供>
261
261
 
262
262
  `/forkgroup` 会根据话题状态选择来源。话题尚未绑定任务,或者已绑定任务但尚未完成过自己的 turn 时,直接从话题原始锚点 turn fork,不创建中间话题任务。话题任务完成过 turn 后,使用本地持久化的最近完成 turn;更晚的执行中 turn 不会阻塞命令,也不会成为 fork 点。
263
263
 
264
- Agent Bot 会在每个 `thread/fork` 请求中默认发送实验性的 `excludeTurns: true`。它只阻止响应填充 `thread.turns`,不会改变复制到分支中的历史。App Server 连接已启用 `experimentalApi`,不需要用户提供命令参数。如果旧版 App Server 明确提示 `excludeTurns` 字段未知、不支持或需要实验能力,Agent Bot 会移除该字段并重试一次。超时、断连和无关的 Fork 错误绝不重试,因为第一次请求可能已经创建分支。
264
+ Fork 从未绑定过 Agent Bot 的任务前,Agent Bot 会先读取不含 Turns 的元数据,再通过轻量的 `summary` Turn 视图从新到旧分页,只读到最近一个已完成 Turn,不再把完整工具历史作为 Fork 前置条件。随后每个 `thread/fork` 请求仍默认发送实验性的 `excludeTurns: true`;它只阻止响应填充 `thread.turns`,不会改变复制到分支中的历史。App Server 连接已启用 `experimentalApi`,不需要用户提供命令参数。如果旧版 App Server 明确不支持轻量 Turn 列表或 `excludeTurns` 字段,Agent Bot 会分别回退到兼容的完整历史读取或 Fork 请求。超时、断连和无关的 Fork 错误绝不重试,因为超时的 Fork 请求可能已经创建分支。
265
265
 
266
266
  新群欢迎消息会显示分支任务持久化后的 Provider、模型、思考强度和权限类型;权限类型显示为自动执行或执行前确认。
267
267
 
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@keyou007/agent-bot",
3
- "version": "0.1.19-alpha.1",
3
+ "version": "0.1.20-alpha.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@keyou007/agent-bot",
9
- "version": "0.1.19-alpha.1",
9
+ "version": "0.1.20-alpha.0",
10
10
  "license": "GPL-3.0-only",
11
11
  "dependencies": {
12
12
  "@agentclientprotocol/sdk": "1.2.1",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@keyou007/agent-bot",
3
- "version": "0.1.19-alpha.1",
3
+ "version": "0.1.20-alpha.0",
4
4
  "type": "module",
5
5
  "description": "Use Codex, TraeX, and compatible coding agents from Lark/Feishu.",
6
6
  "license": "GPL-3.0-only",
@@ -74,9 +74,10 @@ agentbot task title "<title>"
74
74
  - `prompt` posts the Prompt to the task's Feishu conversation before submitting it.
75
75
  - `queue` creates a later turn instead of steering the active turn. `nosteer` is an alias.
76
76
  - `stop` requests an Agent interrupt; it does not kill the Agent process.
77
+ - `release` sends a Lark confirmation card for releasing the Agent's shared App Server. It never releases automatically; the user must click **Release** or **Release Now** on the card.
77
78
  - `dismiss --yes` irreversibly dissolves the task's current Feishu group and archives its current task. Use it only after the user explicitly confirms.
78
79
 
79
- From Feishu, `/release` hands App Server tasks back to Codex Desktop without archiving them. When idle, it sends a release card and counts down for five seconds; otherwise it waits for the current Agent's running and queued work. **Release Now** interrupts active work and clears queued Prompts, while **Cancel** cancels the pending release. Because each App Server Agent is shared, releasing affects every task loaded by that Agent process.
80
+ From Feishu, `/release` sends a confirmation card for handing App Server tasks back to Codex Desktop without archiving them. It lists the current Agent's running and queued work and never releases automatically. **Release** confirms an idle release, **Release Now** interrupts active work and clears queued Prompts, and **Cancel** cancels the pending release. Because each App Server Agent is shared, releasing affects every task loaded by that Agent process.
80
81
 
81
82
  Create or branch work in the same conversation:
82
83
 
@@ -113,7 +113,6 @@ export interface TaskReleaseControlData {
113
113
  agentName: string;
114
114
  status: "waiting" | "released";
115
115
  blockingTaskCount: number;
116
- releaseInSeconds?: number;
117
116
  }
118
117
 
119
118
  export interface TaskForkControlData {
package/src/cli/help.ts CHANGED
@@ -45,7 +45,7 @@ export function renderCliHelp(version: string, language: CliLanguage = cliLangua
45
45
  task shell [任务] <命令> 在任务目录执行本地命令
46
46
  task stop [任务] 停止任务
47
47
  task archive [任务] 归档任务
48
- task release [任务] 释放任务所属 Agent 的共享 App Server
48
+ task release [任务] 发送共享 App Server 释放确认卡片
49
49
  task dismiss [任务] --yes 归档任务并解散其当前群聊
50
50
  task title [任务] <标题> 重命名任务
51
51
  task restart [任务] [--force] 从任务会话请求安全或立即重启
@@ -138,7 +138,7 @@ Task commands:
138
138
  task shell [task] <command> Run a local command in the task directory
139
139
  task stop [task] Stop a task
140
140
  task archive [task] Archive a task
141
- task release [task] Release the task Agent's shared App Server
141
+ task release [task] Send a confirmation card to release the shared App Server
142
142
  task dismiss [task] --yes Archive a task and dissolve its current group
143
143
  task title [task] <title> Rename a task
144
144
  task restart [task] [--force] Request a safe or immediate restart from a task
package/src/cli.ts CHANGED
@@ -1337,15 +1337,14 @@ async function taskCommand(input: string[]): Promise<void> {
1337
1337
  `App Server tasks released for ${released.agentName}.\n`,
1338
1338
  `已释放 ${released.agentName} 的 App Server 任务。\n`,
1339
1339
  ));
1340
- } else if (released.releaseInSeconds !== undefined) {
1341
- process.stdout.write(cliText(
1342
- `App Server release scheduled for ${released.agentName} in ${released.releaseInSeconds} seconds.\n`,
1343
- `已安排在 ${released.releaseInSeconds} 秒后释放 ${released.agentName} 的 App Server。\n`,
1344
- ));
1345
1340
  } else {
1346
1341
  process.stdout.write(cliText(
1347
- `App Server release scheduled for ${released.agentName} (${released.blockingTaskCount} waiting).\n`,
1348
- `已安排释放 ${released.agentName} 的 App Server(等待 ${released.blockingTaskCount} 个任务)。\n`,
1342
+ released.blockingTaskCount > 0
1343
+ ? `App Server release card sent for ${released.agentName} (${released.blockingTaskCount} blocking). Confirm in Lark to release.\n`
1344
+ : `App Server release card sent for ${released.agentName}. Confirm in Lark to release.\n`,
1345
+ released.blockingTaskCount > 0
1346
+ ? `已发送 ${released.agentName} 的 App Server 释放卡片(${released.blockingTaskCount} 个任务阻塞)。请在飞书中确认释放。\n`
1347
+ : `已发送 ${released.agentName} 的 App Server 释放卡片。请在飞书中确认释放。\n`,
1349
1348
  ));
1350
1349
  }
1351
1350
  return;
@@ -42,6 +42,8 @@ const SESSION_REQUEST_TIMEOUT_MS = 60_000;
42
42
  const CONTROL_REQUEST_TIMEOUT_MS = 10_000;
43
43
  const SYNC_REQUEST_TIMEOUT_MS = 5_000;
44
44
  const THREAD_TURN_PAGE_SIZE = 100;
45
+ const FORK_SOURCE_TURN_PAGE_SIZE = 20;
46
+ const FORK_SOURCE_REQUEST_TIMEOUT_MS = 15_000;
45
47
  const USER_RESUMABLE_THREAD_SOURCE_KINDS = ["cli", "vscode", "exec", "appServer"] as const;
46
48
  // A timed-out fork keeps running in App Server and can create an orphan thread.
47
49
  // Wait for its response; connection closure still rejects the request.
@@ -305,7 +307,37 @@ export class CodexRuntime implements AgentRuntime {
305
307
  async readRemoteSession(remoteSessionId: string): Promise<RemoteSessionSummary> {
306
308
  const client = await this.client();
307
309
  const response = await this.readThreadWithTurns(client, remoteSessionId);
308
- 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> {
309
341
  const activeThreads = await this.localActivityDetector?.activeThreads([remoteSessionId]);
310
342
  const activeSummary = activeThreads ? markLocallyDetectedActive(summary, activeThreads) : summary;
311
343
  const settings = await this.localActivityDetector?.threadSettings([remoteSessionId]);
@@ -315,6 +347,46 @@ export class CodexRuntime implements AgentRuntime {
315
347
  };
316
348
  }
317
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
+
318
390
  private async readThreadWithTurns(
319
391
  client: AppServerClient,
320
392
  remoteSessionId: string,
@@ -1229,6 +1301,12 @@ function isPaginatedThreadReadError(error: unknown): boolean {
1229
1301
  return /paginated threads?[^\n]*do(?:es)? not support[^\n]*thread\/read[^\n]*includeTurns\s*=\s*true/i.test(details);
1230
1302
  }
1231
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
+
1232
1310
  function isUnmaterializedThreadReadError(error: unknown): boolean {
1233
1311
  const details = appServerErrorDetails(error);
1234
1312
  return /thread[^\n]*(?:not materialized|not loaded)[^\n]*includeTurns[^\n]*(?:unavailable|before first user message)/i
@@ -281,7 +281,7 @@ export interface AppServerReleaseCardView {
281
281
  scheduleId: number;
282
282
  agentName: string;
283
283
  blockingTaskCount?: number;
284
- releaseInSeconds?: number;
284
+ blockingTaskTitles?: string[];
285
285
  forced?: boolean;
286
286
  error?: string;
287
287
  }
@@ -405,16 +405,20 @@ export class CardRenderer {
405
405
  }
406
406
 
407
407
  const blockingTaskCount = Math.max(0, view.blockingTaskCount ?? 0);
408
- const releaseInSeconds = Math.max(1, view.releaseInSeconds ?? 1);
409
- return sectionCard("等待释放", [
408
+ const blockingTaskLines = (view.blockingTaskTitles ?? []).map((title, index) => {
409
+ const normalizedTitle = title.replace(/\s+/g, " ").trim() || "未命名任务";
410
+ return `${index + 1}. ${inlineCode(truncateText(normalizedTitle, 80))}`;
411
+ });
412
+ return sectionCard(blockingTaskCount > 0 ? "等待释放" : "确认释放", [
410
413
  markdown([
411
414
  `**Agent**:${agent}`,
412
415
  blockingTaskCount > 0
413
416
  ? `**等待任务**:${blockingTaskCount}`
414
- : `**自动释放**:${releaseInSeconds} 秒后`,
417
+ : "**状态**:可以释放",
418
+ ...blockingTaskLines,
415
419
  blockingTaskCount > 0
416
- ? "> 任务结束后自动释放;立即释放会中断执行。"
417
- : "> 可立即释放或取消。",
420
+ ? "> 点击 **Release Now** 会中断执行;也可等待任务结束后再释放。"
421
+ : "> 点击 **Release** 释放,或取消。",
418
422
  ].join("\n")),
419
423
  {
420
424
  tag: "column_set",
@@ -428,7 +432,7 @@ export class CardRenderer {
428
432
  vertical_align: "center",
429
433
  elements: [{
430
434
  tag: "button",
431
- text: { tag: "plain_text", content: "Release Now" },
435
+ text: { tag: "plain_text", content: blockingTaskCount > 0 ? "Release Now" : "Release" },
432
436
  type: "danger",
433
437
  size: "small",
434
438
  behaviors: [{
@@ -143,10 +143,9 @@ interface AppServerReleaseSchedule {
143
143
  runtime: AgentRuntime;
144
144
  status: AppServerReleaseCardView["status"];
145
145
  blockingTaskCount: number;
146
+ blockingTaskTitles: string[];
146
147
  targets: Map<string, AppServerReleaseCardTarget>;
147
148
  forced: boolean;
148
- releaseAt?: number;
149
- releaseCountdownSeconds?: number;
150
149
  releaseStarted?: boolean;
151
150
  error?: string;
152
151
  timer?: NodeJS.Timeout;
@@ -184,7 +183,6 @@ const RECOVERY_HEARTBEAT_INTERVAL_MS = 60 * 1_000;
184
183
  const SHELL_COMMAND_JOB_POLL_INTERVAL_MS = 1_000;
185
184
  const SHELL_COMMAND_JOB_RELAUNCH_DELAY_MS = 5_000;
186
185
  const APP_SERVER_RELEASE_POLL_INTERVAL_MS = 500;
187
- const APP_SERVER_RELEASE_IDLE_DELAY_MS = 5_000;
188
186
  const FORWARD_ATTACHMENT_WINDOW_MS = 800;
189
187
  const DEFAULT_MERGED_FORWARD_INSTRUCTION = "请参考以下内容回复用户";
190
188
  const DEFAULT_REFERENCED_MESSAGE_INSTRUCTION = "请参考引用消息回复用户";
@@ -454,7 +452,6 @@ export interface ControlTaskReleaseResult {
454
452
  agentName: string;
455
453
  status: "waiting" | "released";
456
454
  blockingTaskCount: number;
457
- releaseInSeconds?: number;
458
455
  }
459
456
 
460
457
  export interface ProxyLifecycle {
@@ -1287,7 +1284,6 @@ export class ProxySessionController {
1287
1284
  agentName: result.agentName,
1288
1285
  status: result.status,
1289
1286
  blockingTaskCount: result.blockingTaskCount,
1290
- ...(result.releaseInSeconds !== undefined ? { releaseInSeconds: result.releaseInSeconds } : {}),
1291
1287
  };
1292
1288
  }
1293
1289
 
@@ -3041,7 +3037,7 @@ export class ProxySessionController {
3041
3037
  runtime = this.runtimes.forAgent(agentName);
3042
3038
  } else {
3043
3039
  if (!taskId) throw new Error("缺少要 fork 的 App Server 任务 ID。");
3044
- const resolved = await this.resolveRemoteCodexSession(taskId);
3040
+ const resolved = await this.resolveRemoteCodexSession(taskId, { forFork: true });
3045
3041
  agentName = resolved.agentName;
3046
3042
  runtime = resolved.runtime;
3047
3043
  remote = resolved.remote;
@@ -6218,7 +6214,10 @@ export class ProxySessionController {
6218
6214
  : this.store.findSessionByRemoteSessionId(reference, contextKey);
6219
6215
  }
6220
6216
 
6221
- private async resolveRemoteCodexSession(reference: string): Promise<AgentRemoteSession> {
6217
+ private async resolveRemoteCodexSession(
6218
+ reference: string,
6219
+ options: { forFork?: boolean } = {},
6220
+ ): Promise<AgentRemoteSession> {
6222
6221
  const scoped = parseRemoteSessionReference(reference);
6223
6222
  const candidates = scoped
6224
6223
  ? [[scoped.agentName, this.runtimes.forAgent(scoped.agentName)] as const]
@@ -6229,10 +6228,13 @@ export class ProxySessionController {
6229
6228
  if (runtime.kind !== "codex" || !runtime.readRemoteSession) {
6230
6229
  throw new Error(`Agent ${agentName} 不支持读取远端任务。`);
6231
6230
  }
6231
+ const remoteSessionId = scoped?.remoteSessionId ?? reference;
6232
6232
  return {
6233
6233
  agentName,
6234
6234
  runtime,
6235
- remote: await runtime.readRemoteSession(scoped?.remoteSessionId ?? reference),
6235
+ remote: options.forFork && runtime.readRemoteForkSource
6236
+ ? await runtime.readRemoteForkSource(remoteSessionId)
6237
+ : await runtime.readRemoteSession(remoteSessionId),
6236
6238
  } satisfies AgentRemoteSession;
6237
6239
  }));
6238
6240
  const matches = reads
@@ -6968,7 +6970,6 @@ export class ProxySessionController {
6968
6970
  agentName: string;
6969
6971
  status: "waiting" | "released" | "failed";
6970
6972
  blockingTaskCount: number;
6971
- releaseInSeconds?: number;
6972
6973
  error?: string;
6973
6974
  }> {
6974
6975
  const record = requestedRecord ?? this.requireCurrentSession(contextKey);
@@ -6980,20 +6981,17 @@ export class ProxySessionController {
6980
6981
  let schedule = this.appServerReleaseSchedules.get(record.agentName);
6981
6982
  const created = !schedule;
6982
6983
  if (!schedule) {
6983
- const blockingTaskCount = this.appServerReleaseBlockers(record.agentName, runtime).length;
6984
+ const blockers = this.appServerReleaseBlockers(record.agentName, runtime);
6985
+ const blockingTaskCount = blockers.length;
6984
6986
  schedule = {
6985
6987
  id: this.nextAppServerReleaseScheduleId++,
6986
6988
  agentName: record.agentName,
6987
6989
  runtime,
6988
6990
  status: "waiting",
6989
6991
  blockingTaskCount,
6992
+ blockingTaskTitles: this.appServerReleaseTaskTitles(blockers),
6990
6993
  targets: new Map(),
6991
6994
  forced: false,
6992
- ...(blockingTaskCount === 0
6993
- ? {
6994
- releaseCountdownSeconds: Math.ceil(APP_SERVER_RELEASE_IDLE_DELAY_MS / 1_000),
6995
- }
6996
- : {}),
6997
6995
  };
6998
6996
  this.appServerReleaseSchedules.set(record.agentName, schedule);
6999
6997
  }
@@ -7006,14 +7004,6 @@ export class ProxySessionController {
7006
7004
  if (messageId) {
7007
7005
  schedule.targets.set(`${contextKey}\u0000${messageId}`, { contextKey, messageId });
7008
7006
  }
7009
- if (
7010
- schedule.status === "waiting"
7011
- && schedule.blockingTaskCount === 0
7012
- && schedule.releaseCountdownSeconds !== undefined
7013
- && schedule.releaseAt === undefined
7014
- ) {
7015
- schedule.releaseAt = Date.now() + APP_SERVER_RELEASE_IDLE_DELAY_MS;
7016
- }
7017
7007
  } catch (error) {
7018
7008
  if (created && schedule.targets.size === 0) {
7019
7009
  this.clearAppServerReleaseSchedule(schedule);
@@ -7026,9 +7016,6 @@ export class ProxySessionController {
7026
7016
  agentName: schedule.agentName,
7027
7017
  status: schedule.status === "failed" ? "failed" : schedule.status === "released" ? "released" : "waiting",
7028
7018
  blockingTaskCount: schedule.blockingTaskCount,
7029
- ...(schedule.releaseCountdownSeconds !== undefined
7030
- ? { releaseInSeconds: schedule.releaseCountdownSeconds }
7031
- : {}),
7032
7019
  ...(schedule.error ? { error: schedule.error } : {}),
7033
7020
  };
7034
7021
  }
@@ -7040,7 +7027,7 @@ export class ProxySessionController {
7040
7027
  if (!Number.isSafeInteger(scheduleId) || !schedule || schedule.id !== scheduleId || schedule.status !== "waiting") {
7041
7028
  throw new Error("释放卡片已失效,请重新发送 /release。");
7042
7029
  }
7043
- await this.performAppServerRelease(schedule, true);
7030
+ await this.performAppServerRelease(schedule);
7044
7031
  }
7045
7032
 
7046
7033
  private async cancelAppServerRelease(action: CardAction): Promise<void> {
@@ -7075,6 +7062,10 @@ export class ProxySessionController {
7075
7062
  });
7076
7063
  }
7077
7064
 
7065
+ private appServerReleaseTaskTitles(blockers: SessionRecord[]): string[] {
7066
+ return blockers.map((session) => session.title?.trim() || "未命名任务");
7067
+ }
7068
+
7078
7069
  private armAppServerReleaseCheck(schedule: AppServerReleaseSchedule): void {
7079
7070
  if (schedule.status !== "waiting" || schedule.timer || schedule.inFlight) return;
7080
7071
  schedule.timer = setTimeout(() => {
@@ -7089,36 +7080,22 @@ export class ProxySessionController {
7089
7080
 
7090
7081
  private async refreshAppServerReleaseSchedule(schedule: AppServerReleaseSchedule): Promise<void> {
7091
7082
  if (this.appServerReleaseSchedules.get(schedule.agentName) !== schedule || schedule.status !== "waiting") return;
7092
- const blockingTaskCount = this.appServerReleaseBlockers(schedule.agentName, schedule.runtime).length;
7093
- if (blockingTaskCount > 0) {
7094
- const changed = schedule.blockingTaskCount !== blockingTaskCount || schedule.releaseAt !== undefined;
7095
- schedule.releaseAt = undefined;
7096
- schedule.releaseCountdownSeconds = undefined;
7097
- if (changed) {
7098
- schedule.blockingTaskCount = blockingTaskCount;
7099
- await this.publishAppServerReleaseSchedule(schedule);
7100
- }
7101
- this.armAppServerReleaseCheck(schedule);
7102
- return;
7103
- }
7104
- schedule.blockingTaskCount = 0;
7105
- if (schedule.releaseAt !== undefined) {
7106
- const releaseCountdownSeconds = Math.max(0, Math.ceil((schedule.releaseAt - Date.now()) / 1_000));
7107
- if (releaseCountdownSeconds > 0) {
7108
- if (schedule.releaseCountdownSeconds !== releaseCountdownSeconds) {
7109
- schedule.releaseCountdownSeconds = releaseCountdownSeconds;
7110
- await this.publishAppServerReleaseSchedule(schedule);
7111
- }
7112
- this.armAppServerReleaseCheck(schedule);
7113
- return;
7114
- }
7083
+ const blockers = this.appServerReleaseBlockers(schedule.agentName, schedule.runtime);
7084
+ const blockingTaskCount = blockers.length;
7085
+ const blockingTaskTitles = this.appServerReleaseTaskTitles(blockers);
7086
+ const titlesChanged = schedule.blockingTaskTitles.length !== blockingTaskTitles.length
7087
+ || schedule.blockingTaskTitles.some((title, index) => title !== blockingTaskTitles[index]);
7088
+ if (schedule.blockingTaskCount !== blockingTaskCount || titlesChanged) {
7089
+ schedule.blockingTaskCount = blockingTaskCount;
7090
+ schedule.blockingTaskTitles = blockingTaskTitles;
7091
+ await this.publishAppServerReleaseSchedule(schedule);
7115
7092
  }
7116
- await this.performAppServerRelease(schedule, false);
7093
+ this.armAppServerReleaseCheck(schedule);
7117
7094
  }
7118
7095
 
7119
- private async performAppServerRelease(schedule: AppServerReleaseSchedule, force: boolean): Promise<void> {
7096
+ private async performAppServerRelease(schedule: AppServerReleaseSchedule): Promise<void> {
7120
7097
  if (schedule.inFlight) return schedule.inFlight;
7121
- const operation = this.performAppServerReleaseNow(schedule, force);
7098
+ const operation = this.performAppServerReleaseNow(schedule);
7122
7099
  schedule.inFlight = operation;
7123
7100
  try {
7124
7101
  await operation;
@@ -7128,31 +7105,17 @@ export class ProxySessionController {
7128
7105
  }
7129
7106
  }
7130
7107
 
7131
- private async performAppServerReleaseNow(schedule: AppServerReleaseSchedule, force: boolean): Promise<void> {
7108
+ private async performAppServerReleaseNow(schedule: AppServerReleaseSchedule): Promise<void> {
7132
7109
  if (schedule.timer) {
7133
7110
  clearTimeout(schedule.timer);
7134
7111
  schedule.timer = undefined;
7135
7112
  }
7136
- const interruptedWork = force && this.appServerReleaseBlockers(schedule.agentName, schedule.runtime).length > 0;
7137
- if (force) await this.cancelQueuedPromptsForAppServerRelease(schedule.agentName);
7138
- else {
7139
- const blockingTaskCount = this.appServerReleaseBlockers(schedule.agentName, schedule.runtime).length;
7140
- if (blockingTaskCount > 0) {
7141
- schedule.status = "waiting";
7142
- schedule.blockingTaskCount = blockingTaskCount;
7143
- schedule.releaseAt = undefined;
7144
- schedule.releaseCountdownSeconds = undefined;
7145
- schedule.releaseStarted = false;
7146
- await this.publishAppServerReleaseSchedule(schedule);
7147
- this.armAppServerReleaseCheck(schedule);
7148
- return;
7149
- }
7150
- }
7113
+ const interruptedWork = this.appServerReleaseBlockers(schedule.agentName, schedule.runtime).length > 0;
7114
+ await this.cancelQueuedPromptsForAppServerRelease(schedule.agentName);
7151
7115
 
7152
7116
  schedule.status = "releasing";
7153
7117
  schedule.blockingTaskCount = 0;
7154
- schedule.releaseAt = undefined;
7155
- schedule.releaseCountdownSeconds = undefined;
7118
+ schedule.blockingTaskTitles = [];
7156
7119
  schedule.releaseStarted = false;
7157
7120
  schedule.forced = interruptedWork;
7158
7121
  await this.publishAppServerReleaseSchedule(schedule);
@@ -7162,18 +7125,18 @@ export class ProxySessionController {
7162
7125
  ) return;
7163
7126
  schedule.releaseStarted = true;
7164
7127
  try {
7165
- const result = await schedule.runtime.release?.({ force });
7128
+ const result = await schedule.runtime.release?.({ force: true });
7166
7129
  if (!result) throw new Error("当前 Agent 不支持释放 App Server。");
7167
7130
  if (result.status === "busy") {
7131
+ const blockers = this.appServerReleaseBlockers(schedule.agentName, schedule.runtime);
7168
7132
  schedule.status = "waiting";
7169
7133
  schedule.blockingTaskCount = Math.max(
7170
7134
  result.activeSessionIds?.length ?? 0,
7171
- this.appServerReleaseBlockers(schedule.agentName, schedule.runtime).length,
7135
+ blockers.length,
7172
7136
  1,
7173
7137
  );
7138
+ schedule.blockingTaskTitles = this.appServerReleaseTaskTitles(blockers);
7174
7139
  schedule.forced = false;
7175
- schedule.releaseAt = undefined;
7176
- schedule.releaseCountdownSeconds = undefined;
7177
7140
  schedule.releaseStarted = false;
7178
7141
  await this.publishAppServerReleaseSchedule(schedule);
7179
7142
  this.armAppServerReleaseCheck(schedule);
@@ -7233,9 +7196,7 @@ export class ProxySessionController {
7233
7196
  scheduleId: schedule.id,
7234
7197
  agentName: schedule.agentName,
7235
7198
  ...(schedule.blockingTaskCount > 0 ? { blockingTaskCount: schedule.blockingTaskCount } : {}),
7236
- ...(schedule.releaseCountdownSeconds !== undefined
7237
- ? { releaseInSeconds: schedule.releaseCountdownSeconds }
7238
- : {}),
7199
+ ...(schedule.blockingTaskTitles.length > 0 ? { blockingTaskTitles: schedule.blockingTaskTitles } : {}),
7239
7200
  ...(schedule.forced ? { forced: true } : {}),
7240
7201
  ...(schedule.error ? { error: schedule.error } : {}),
7241
7202
  };
@@ -230,6 +230,7 @@ export interface AgentRuntime {
230
230
  readSessionMetadata(remoteSessionId: string): Promise<RuntimeSessionMetadata>;
231
231
  listRemoteSessions?(input?: { searchTerm?: string; cursor?: string; limit?: number }): Promise<RemoteSessionPage>;
232
232
  readRemoteSession?(remoteSessionId: string): Promise<RemoteSessionSummary>;
233
+ readRemoteForkSource?(remoteSessionId: string): Promise<RemoteSessionSummary>;
233
234
  inspectRemoteSessionActivity?(remoteSessionId: string): Promise<RemoteSessionActivity>;
234
235
  synchronizeSession(sessionId: string): Promise<RuntimeSession>;
235
236
  startTurn(sessionId: string, prompt: RuntimePrompt): Promise<string>;