@keyou007/agent-bot 0.1.18 → 0.1.19-alpha.1

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 (63) hide show
  1. package/CHANGELOG.md +11 -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 +43 -5
  12. package/dist/codex/CodexRuntime.js.map +1 -1
  13. package/dist/codex/ThreadWriterProcess.js +344 -0
  14. package/dist/codex/ThreadWriterProcess.js.map +1 -0
  15. package/dist/commands/CommandRouter.js +5 -0
  16. package/dist/commands/CommandRouter.js.map +1 -1
  17. package/dist/console/ConsoleTurnPresenter.js +1 -0
  18. package/dist/console/ConsoleTurnPresenter.js.map +1 -1
  19. package/dist/feishu/CardRenderer.js +166 -0
  20. package/dist/feishu/CardRenderer.js.map +1 -1
  21. package/dist/feishu/FeishuTurnPresenter.js +13 -3
  22. package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
  23. package/dist/feishu/MergedForwardMessage.js +6 -1
  24. package/dist/feishu/MergedForwardMessage.js.map +1 -1
  25. package/dist/index.js +4 -2
  26. package/dist/index.js.map +1 -1
  27. package/dist/logging/errorLogValue.js +18 -0
  28. package/dist/logging/errorLogValue.js.map +1 -0
  29. package/dist/logging/logger.js +4 -0
  30. package/dist/logging/logger.js.map +1 -1
  31. package/dist/presentation/OutboundRouter.js +2 -2
  32. package/dist/presentation/OutboundRouter.js.map +1 -1
  33. package/dist/proxy/ProxySessionController.js +778 -19
  34. package/dist/proxy/ProxySessionController.js.map +1 -1
  35. package/dist/state/StateStore.js +10 -0
  36. package/dist/state/StateStore.js.map +1 -1
  37. package/dist/supervision/SafeRestartScheduler.js +1 -1
  38. package/dist/supervision/SafeRestartScheduler.js.map +1 -1
  39. package/docs/technical-reference.md +1 -1
  40. package/docs/technical-reference.zh.md +1 -1
  41. package/npm-shrinkwrap.json +2 -2
  42. package/package.json +1 -1
  43. package/skills/agent-bot/SKILL.md +3 -0
  44. package/src/cli/controlProtocol.ts +8 -0
  45. package/src/cli/help.ts +2 -0
  46. package/src/cli.ts +33 -0
  47. package/src/codex/CodexProcessManager.ts +43 -0
  48. package/src/codex/CodexRuntime.ts +41 -4
  49. package/src/codex/ThreadWriterProcess.ts +385 -0
  50. package/src/commands/CommandRouter.ts +4 -0
  51. package/src/commands/commandTypes.ts +1 -0
  52. package/src/console/ConsoleTurnPresenter.ts +2 -1
  53. package/src/feishu/CardRenderer.ts +205 -0
  54. package/src/feishu/FeishuTurnPresenter.ts +16 -3
  55. package/src/feishu/MergedForwardMessage.ts +5 -1
  56. package/src/index.ts +4 -2
  57. package/src/logging/errorLogValue.ts +26 -0
  58. package/src/logging/logger.ts +4 -0
  59. package/src/presentation/OutboundRouter.ts +15 -3
  60. package/src/proxy/ProxySessionController.ts +1003 -30
  61. package/src/runtime/types.ts +7 -0
  62. package/src/state/StateStore.ts +11 -0
  63. package/src/supervision/SafeRestartScheduler.ts +1 -1
@@ -0,0 +1,385 @@
1
+ import { execFile } from "node:child_process";
2
+ import { access, readdir } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ const PROCESS_INSPECTION_TIMEOUT_MS = 10_000;
6
+ const MAX_PROCESS_OUTPUT_BYTES = 1024 * 1024;
7
+ const LOCK_INSPECTION_CONCURRENCY = 4;
8
+ const THREAD_WRITER_LOCK_FILE_PATTERN = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.lock$/iu;
9
+
10
+ export interface ThreadWriterProcess {
11
+ writerPid: number;
12
+ writerProcessName: string;
13
+ writerStartedAt?: string;
14
+ applicationPid: number;
15
+ applicationProcessName: string;
16
+ applicationStartedAt?: string;
17
+ displayName: string;
18
+ canClose: boolean;
19
+ commandLine?: string;
20
+ }
21
+
22
+ export interface ThreadWriterProcessController {
23
+ inspect(lockPath: string): Promise<ThreadWriterProcess[]>;
24
+ inspectApplicationThreadIds?(lockPath: string, owner: ThreadWriterProcess): Promise<string[]>;
25
+ close(process: ThreadWriterProcess, force: boolean): Promise<void>;
26
+ }
27
+
28
+ export class SystemThreadWriterProcessController implements ThreadWriterProcessController {
29
+ async inspect(lockPath: string): Promise<ThreadWriterProcess[]> {
30
+ try {
31
+ await access(lockPath);
32
+ } catch {
33
+ return [];
34
+ }
35
+ return process.platform === "win32"
36
+ ? inspectWindowsLockOwners(lockPath)
37
+ : inspectPosixLockOwners(lockPath);
38
+ }
39
+
40
+ async inspectApplicationThreadIds(lockPath: string, owner: ThreadWriterProcess): Promise<string[]> {
41
+ if (!owner.applicationStartedAt) return [];
42
+ let entries;
43
+ try {
44
+ entries = await readdir(path.dirname(lockPath), { withFileTypes: true });
45
+ } catch {
46
+ return [];
47
+ }
48
+ const candidates = entries.flatMap((entry) => {
49
+ if (!entry.isFile()) return [];
50
+ const match = THREAD_WRITER_LOCK_FILE_PATTERN.exec(entry.name);
51
+ return match?.[1] ? [{ threadId: match[1], lockPath: path.join(path.dirname(lockPath), entry.name) }] : [];
52
+ });
53
+ const ownedThreadIds: string[] = [];
54
+ for (let index = 0; index < candidates.length; index += LOCK_INSPECTION_CONCURRENCY) {
55
+ const batch = candidates.slice(index, index + LOCK_INSPECTION_CONCURRENCY);
56
+ const results = await Promise.all(batch.map(async (candidate) => {
57
+ const owners = samePath(candidate.lockPath, lockPath)
58
+ ? [owner]
59
+ : await this.inspect(candidate.lockPath);
60
+ return owners.some((candidateOwner) => sameApplication(candidateOwner, owner))
61
+ ? candidate.threadId
62
+ : undefined;
63
+ }));
64
+ ownedThreadIds.push(...results.filter((threadId): threadId is string => Boolean(threadId)));
65
+ }
66
+ return ownedThreadIds;
67
+ }
68
+
69
+ async close(owner: ThreadWriterProcess, force: boolean): Promise<void> {
70
+ const targetPid = owner.applicationPid;
71
+ if (!owner.canClose) throw new Error("The writer process is not recognized as a closable Agent application.");
72
+ if (!owner.applicationStartedAt) throw new Error("The writer process does not have a stable start-time fingerprint.");
73
+ if (!Number.isSafeInteger(targetPid) || targetPid <= 4 || targetPid === process.pid) {
74
+ throw new Error(`Refusing to close unsafe process PID ${targetPid}.`);
75
+ }
76
+ if (process.platform === "win32") {
77
+ await executeWindowsPowerShell(WINDOWS_CLOSE_PROCESS_SCRIPT, {
78
+ AGENT_BOT_CLOSE_PID: String(targetPid),
79
+ AGENT_BOT_CLOSE_STARTED_AT: owner.applicationStartedAt,
80
+ AGENT_BOT_FORCE_CLOSE: force ? "true" : "false",
81
+ AGENT_BOT_WRITER_PID: String(owner.writerPid),
82
+ });
83
+ return;
84
+ }
85
+ const current = await readPosixProcess(targetPid);
86
+ if (!current || current.startedAt !== owner.applicationStartedAt) {
87
+ throw new Error(`Process PID ${targetPid} no longer matches the inspected writer application.`);
88
+ }
89
+ process.kill(targetPid, force ? "SIGKILL" : "SIGTERM");
90
+ }
91
+ }
92
+
93
+ export function threadWriterLockPath(codexHome: string, threadId: string): string | undefined {
94
+ if (!THREAD_WRITER_LOCK_FILE_PATTERN.test(`${threadId}.lock`)) {
95
+ return undefined;
96
+ }
97
+ return path.join(codexHome, "thread-writer-locks", `${threadId}.lock`);
98
+ }
99
+
100
+ export function parseWindowsLockOwners(value: string): ThreadWriterProcess[] {
101
+ const trimmed = value.trim();
102
+ if (!trimmed) return [];
103
+ const parsed: unknown = JSON.parse(trimmed);
104
+ const entries = Array.isArray(parsed) ? parsed : [parsed];
105
+ return entries.flatMap((entry) => {
106
+ if (!entry || typeof entry !== "object") return [];
107
+ const record = entry as Record<string, unknown>;
108
+ const writerPid = positiveInteger(record.writerPid);
109
+ const applicationPid = positiveInteger(record.applicationPid);
110
+ const writerProcessName = stringValue(record.writerProcessName);
111
+ const applicationProcessName = stringValue(record.applicationProcessName);
112
+ if (!writerPid || !applicationPid || !writerProcessName || !applicationProcessName) return [];
113
+ return [{
114
+ writerPid,
115
+ writerProcessName,
116
+ writerStartedAt: stringValue(record.writerStartedAt),
117
+ applicationPid,
118
+ applicationProcessName,
119
+ applicationStartedAt: stringValue(record.applicationStartedAt),
120
+ displayName: stringValue(record.displayName) ?? applicationProcessName,
121
+ canClose: record.canClose === true,
122
+ commandLine: stringValue(record.commandLine),
123
+ }];
124
+ });
125
+ }
126
+
127
+ async function inspectWindowsLockOwners(lockPath: string): Promise<ThreadWriterProcess[]> {
128
+ const output = await executeWindowsPowerShell(WINDOWS_LOCK_OWNER_SCRIPT, {
129
+ AGENT_BOT_THREAD_LOCK_PATH: lockPath,
130
+ });
131
+ return parseWindowsLockOwners(output);
132
+ }
133
+
134
+ async function inspectPosixLockOwners(lockPath: string): Promise<ThreadWriterProcess[]> {
135
+ const pids = await posixLockOwnerPids(lockPath);
136
+ const owners = await Promise.all(pids.map((pid) => inspectPosixProcess(pid)));
137
+ return owners.filter((owner): owner is ThreadWriterProcess => owner !== undefined);
138
+ }
139
+
140
+ async function posixLockOwnerPids(lockPath: string): Promise<number[]> {
141
+ const lsof = await tryExecuteFile("lsof", ["-t", "--", lockPath]);
142
+ const raw = lsof.ok ? lsof.stdout : (await tryExecuteFile("fuser", [lockPath])).stdout;
143
+ return [...new Set(raw.match(/\d+/gu)?.map(Number).filter((pid) => Number.isSafeInteger(pid) && pid > 0) ?? [])];
144
+ }
145
+
146
+ async function inspectPosixProcess(writerPid: number): Promise<ThreadWriterProcess | undefined> {
147
+ const writer = await readPosixProcess(writerPid);
148
+ if (!writer) return undefined;
149
+ let application = writer;
150
+ let cursor = writer;
151
+ for (let depth = 0; depth < 8 && cursor.parentPid > 1; depth += 1) {
152
+ const parent = await readPosixProcess(cursor.parentPid);
153
+ if (!parent) break;
154
+ if (/\/(?:Codex|ChatGPT)\.app\//iu.test(parent.commandLine)) {
155
+ application = parent;
156
+ break;
157
+ }
158
+ cursor = parent;
159
+ }
160
+ const canClose = isRecognizedWriter(writer.name, writer.commandLine);
161
+ return {
162
+ writerPid,
163
+ writerProcessName: writer.name,
164
+ writerStartedAt: writer.startedAt,
165
+ applicationPid: application.pid,
166
+ applicationProcessName: application.name,
167
+ applicationStartedAt: application.startedAt,
168
+ displayName: /\/(?:Codex|ChatGPT)\.app\//iu.test(application.commandLine)
169
+ ? "Codex Desktop"
170
+ : writer.name,
171
+ canClose,
172
+ commandLine: writer.commandLine,
173
+ };
174
+ }
175
+
176
+ interface PosixProcessInfo {
177
+ pid: number;
178
+ parentPid: number;
179
+ name: string;
180
+ startedAt?: string;
181
+ commandLine: string;
182
+ }
183
+
184
+ async function readPosixProcess(pid: number): Promise<PosixProcessInfo | undefined> {
185
+ const [parent, name, startedAt, commandLine] = await Promise.all([
186
+ tryExecuteFile("ps", ["-p", String(pid), "-o", "ppid="]),
187
+ tryExecuteFile("ps", ["-p", String(pid), "-o", "comm="]),
188
+ tryExecuteFile("ps", ["-p", String(pid), "-o", "lstart="]),
189
+ tryExecuteFile("ps", ["-p", String(pid), "-o", "command="]),
190
+ ]);
191
+ const parentPid = Number(parent.stdout.trim());
192
+ const processName = name.stdout.trim();
193
+ if (!parent.ok || !processName || !Number.isSafeInteger(parentPid)) return undefined;
194
+ return {
195
+ pid,
196
+ parentPid,
197
+ name: processName,
198
+ startedAt: startedAt.stdout.trim() || undefined,
199
+ commandLine: commandLine.stdout.trim(),
200
+ };
201
+ }
202
+
203
+ function isRecognizedWriter(name: string, commandLine: string): boolean {
204
+ const candidate = `${name} ${commandLine}`;
205
+ return /(?:^|[\\/\s])(?:codex|trae(?:x|cli)?)(?:\.exe|\.js)?(?:\s|$)/iu.test(candidate)
206
+ && /app-server/iu.test(candidate);
207
+ }
208
+
209
+ async function executeWindowsPowerShell(script: string, extraEnv: Record<string, string>): Promise<string> {
210
+ const encoded = Buffer.from(script, "utf16le").toString("base64");
211
+ return executeFile(
212
+ "powershell.exe",
213
+ ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded],
214
+ extraEnv,
215
+ );
216
+ }
217
+
218
+ function executeFile(command: string, args: string[], extraEnv: Record<string, string> = {}): Promise<string> {
219
+ return new Promise((resolve, reject) => {
220
+ execFile(command, args, {
221
+ env: { ...process.env, ...extraEnv },
222
+ timeout: PROCESS_INSPECTION_TIMEOUT_MS,
223
+ maxBuffer: MAX_PROCESS_OUTPUT_BYTES,
224
+ windowsHide: true,
225
+ encoding: "utf8",
226
+ }, (error, stdout, stderr) => {
227
+ if (error) {
228
+ reject(new Error(`${command} failed: ${stderr.trim() || error.message}`, { cause: error }));
229
+ return;
230
+ }
231
+ resolve(stdout);
232
+ });
233
+ });
234
+ }
235
+
236
+ function tryExecuteFile(command: string, args: string[]): Promise<{ ok: boolean; stdout: string }> {
237
+ return executeFile(command, args).then(
238
+ (stdout) => ({ ok: true, stdout }),
239
+ () => ({ ok: false, stdout: "" }),
240
+ );
241
+ }
242
+
243
+ function positiveInteger(value: unknown): number | undefined {
244
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined;
245
+ }
246
+
247
+ function stringValue(value: unknown): string | undefined {
248
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
249
+ }
250
+
251
+ function sameApplication(left: ThreadWriterProcess, right: ThreadWriterProcess): boolean {
252
+ return left.applicationPid === right.applicationPid
253
+ && Boolean(left.applicationStartedAt)
254
+ && left.applicationStartedAt === right.applicationStartedAt;
255
+ }
256
+
257
+ function samePath(left: string, right: string): boolean {
258
+ const normalizedLeft = path.resolve(left);
259
+ const normalizedRight = path.resolve(right);
260
+ return process.platform === "win32"
261
+ ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
262
+ : normalizedLeft === normalizedRight;
263
+ }
264
+
265
+ const WINDOWS_CLOSE_PROCESS_SCRIPT = String.raw`
266
+ $ErrorActionPreference = 'Stop'
267
+ $targetPid = [int]$env:AGENT_BOT_CLOSE_PID
268
+ $writerPid = [int]$env:AGENT_BOT_WRITER_PID
269
+ $expectedStartedAt = $env:AGENT_BOT_CLOSE_STARTED_AT
270
+ $forceClose = $env:AGENT_BOT_FORCE_CLOSE -eq 'true'
271
+ $process = Get-Process -Id $targetPid -ErrorAction Stop
272
+ $actualStartedAt = $process.StartTime.ToUniversalTime().ToString('o')
273
+ if ($actualStartedAt -ne $expectedStartedAt) {
274
+ throw "Process PID $targetPid no longer matches the inspected writer application."
275
+ }
276
+ if ($forceClose) {
277
+ & taskkill.exe /PID $targetPid /T /F | Out-Null
278
+ if ($LASTEXITCODE -ne 0) { throw "taskkill failed with exit code $LASTEXITCODE" }
279
+ } elseif ($targetPid -ne $writerPid -and $process.MainWindowHandle -ne 0) {
280
+ if (-not $process.CloseMainWindow()) {
281
+ throw "The application did not accept a close request."
282
+ }
283
+ } else {
284
+ Stop-Process -Id $targetPid -ErrorAction Stop
285
+ }
286
+ `;
287
+
288
+ const WINDOWS_LOCK_OWNER_SCRIPT = String.raw`
289
+ $ErrorActionPreference = 'Stop'
290
+ Add-Type @'
291
+ using System;
292
+ using System.Runtime.InteropServices;
293
+ using System.Text;
294
+ public static class AgentBotRestartManager {
295
+ [StructLayout(LayoutKind.Sequential)]
296
+ public struct RM_UNIQUE_PROCESS {
297
+ public int dwProcessId;
298
+ public System.Runtime.InteropServices.ComTypes.FILETIME ProcessStartTime;
299
+ }
300
+ public enum RM_APP_TYPE { UnknownApp=0, MainWindow=1, OtherWindow=2, Service=3, Explorer=4, Console=5, Critical=1000 }
301
+ [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)]
302
+ public struct RM_PROCESS_INFO {
303
+ public RM_UNIQUE_PROCESS Process;
304
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst=256)] public string strAppName;
305
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst=64)] public string strServiceShortName;
306
+ public RM_APP_TYPE ApplicationType;
307
+ public uint AppStatus;
308
+ public uint TSSessionId;
309
+ [MarshalAs(UnmanagedType.Bool)] public bool bRestartable;
310
+ }
311
+ [DllImport("rstrtmgr.dll", CharSet=CharSet.Unicode)]
312
+ public static extern int RmStartSession(out uint sessionHandle, int sessionFlags, StringBuilder sessionKey);
313
+ [DllImport("rstrtmgr.dll", CharSet=CharSet.Unicode)]
314
+ public static extern int RmRegisterResources(uint sessionHandle, uint fileCount, string[] files, uint applicationCount, RM_UNIQUE_PROCESS[] applications, uint serviceCount, string[] services);
315
+ [DllImport("rstrtmgr.dll")]
316
+ public static extern int RmGetList(uint sessionHandle, out uint needed, ref uint count, [In, Out] RM_PROCESS_INFO[] processes, ref uint rebootReasons);
317
+ [DllImport("rstrtmgr.dll")]
318
+ public static extern int RmEndSession(uint sessionHandle);
319
+ }
320
+ '@
321
+
322
+ function Get-AgentBotProcess([int]$processId) {
323
+ Get-CimInstance Win32_Process -Filter ("ProcessId=" + $processId) -ErrorAction SilentlyContinue
324
+ }
325
+
326
+ function Get-AgentBotStartTime([int]$processId) {
327
+ try { (Get-Process -Id $processId -ErrorAction Stop).StartTime.ToUniversalTime().ToString('o') } catch { $null }
328
+ }
329
+
330
+ $lockPath = $env:AGENT_BOT_THREAD_LOCK_PATH
331
+ $key = New-Object Text.StringBuilder 64
332
+ [uint32]$handle = 0
333
+ $result = [AgentBotRestartManager]::RmStartSession([ref]$handle, 0, $key)
334
+ if ($result -ne 0) { throw "RmStartSession failed: $result" }
335
+ try {
336
+ $result = [AgentBotRestartManager]::RmRegisterResources($handle, 1, [string[]]@($lockPath), 0, $null, 0, $null)
337
+ if ($result -ne 0) { throw "RmRegisterResources failed: $result" }
338
+ [uint32]$needed = 0
339
+ [uint32]$count = 0
340
+ [uint32]$rebootReasons = 0
341
+ $result = [AgentBotRestartManager]::RmGetList($handle, [ref]$needed, [ref]$count, $null, [ref]$rebootReasons)
342
+ if ($result -eq 0) {
343
+ ConvertTo-Json -Compress -InputObject @()
344
+ return
345
+ }
346
+ if ($result -ne 234) { throw "RmGetList failed: $result" }
347
+ $items = New-Object 'AgentBotRestartManager+RM_PROCESS_INFO[]' $needed
348
+ $count = $needed
349
+ $result = [AgentBotRestartManager]::RmGetList($handle, [ref]$needed, [ref]$count, $items, [ref]$rebootReasons)
350
+ if ($result -ne 0) { throw "RmGetList failed: $result" }
351
+ $owners = @()
352
+ for ($index = 0; $index -lt $count; $index++) {
353
+ $writerPid = $items[$index].Process.dwProcessId
354
+ $writer = Get-AgentBotProcess $writerPid
355
+ if ($null -eq $writer) { continue }
356
+ $application = $writer
357
+ $cursor = $writer
358
+ for ($depth = 0; $depth -lt 8 -and $cursor.ParentProcessId -gt 1; $depth++) {
359
+ $parent = Get-AgentBotProcess $cursor.ParentProcessId
360
+ if ($null -eq $parent) { break }
361
+ if ($parent.Name -ieq 'ChatGPT.exe' -and $parent.ExecutablePath -match 'OpenAI[.\\]Codex') {
362
+ $application = $parent
363
+ break
364
+ }
365
+ $cursor = $parent
366
+ }
367
+ $recognized = (($writer.Name -match '^(codex|trae|traex|traecli)(.exe)?$') -or ($writer.CommandLine -match '(codex|trae|traex|traecli).*(app-server)'))
368
+ $desktop = $application.Name -ieq 'ChatGPT.exe' -and $application.ExecutablePath -match 'OpenAI[.\\]Codex'
369
+ $owners += [pscustomobject]@{
370
+ writerPid = $writerPid
371
+ writerProcessName = $writer.Name
372
+ writerStartedAt = Get-AgentBotStartTime $writerPid
373
+ applicationPid = [int]$application.ProcessId
374
+ applicationProcessName = $application.Name
375
+ applicationStartedAt = Get-AgentBotStartTime $application.ProcessId
376
+ displayName = $(if ($desktop) { 'Codex Desktop' } else { $writer.Name })
377
+ canClose = [bool]$recognized
378
+ commandLine = $writer.CommandLine
379
+ }
380
+ }
381
+ ConvertTo-Json -Compress -InputObject @($owners)
382
+ } finally {
383
+ [void][AgentBotRestartManager]::RmEndSession($handle)
384
+ }
385
+ `;
@@ -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" }
@@ -32,9 +32,10 @@ export class ConsoleTurnPresenter implements TurnPresenter {
32
32
  return undefined;
33
33
  }
34
34
 
35
- async failPendingTurn(sessionId: string, message: string): Promise<void> {
35
+ async failPendingTurn(sessionId: string, message: string): Promise<boolean> {
36
36
  const contextKey = this.contexts.get(sessionId);
37
37
  if (contextKey) await this.outbound.sendText(contextKey, `${this.agentLabel(sessionId)} failed to start: ${message}`);
38
+ return false;
38
39
  }
39
40
 
40
41
  async interruptTurnForRecovery(): Promise<void> {}
@@ -255,6 +255,37 @@ export interface ShellCommandCardView {
255
255
  outputTruncated: boolean;
256
256
  }
257
257
 
258
+ export interface ThreadWriterConflictCardView {
259
+ status: "occupied" | "force_required" | "released";
260
+ contextKey: string;
261
+ sessionId: string;
262
+ threadId: string;
263
+ taskTitle?: string;
264
+ notice?: string;
265
+ applicationIdle?: boolean;
266
+ owner?: {
267
+ displayName: string;
268
+ writerPid: number;
269
+ writerProcessName: string;
270
+ writerStartedAt?: string;
271
+ applicationPid: number;
272
+ applicationProcessName: string;
273
+ applicationStartedAt?: string;
274
+ canClose: boolean;
275
+ };
276
+ }
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
+
258
289
  export class CardRenderer {
259
290
  private readonly thinkingCardLayout: ThinkingCardLayout;
260
291
 
@@ -262,6 +293,180 @@ export class CardRenderer {
262
293
  this.thinkingCardLayout = options.thinkingCardLayout ?? "grouped";
263
294
  }
264
295
 
296
+ renderThreadWriterConflict(view: ThreadWriterConflictCardView): Record<string, unknown> {
297
+ if (view.status === "released") {
298
+ return sectionCard("任务占用已解除", [markdown([
299
+ view.taskTitle ? `**任务**:${inlineCode(view.taskTitle)}` : undefined,
300
+ view.notice ?? "请重新发送消息。",
301
+ ].filter((line): line is string => Boolean(line)).join("\n"))], "green");
302
+ }
303
+
304
+ const owner = view.owner;
305
+ const lines = [
306
+ view.taskTitle ? `**任务**:${inlineCode(view.taskTitle)}` : undefined,
307
+ owner ? `**进程**:${inlineCode(`${owner.applicationProcessName} (${owner.applicationPid})`)}` : undefined,
308
+ view.notice,
309
+ owner
310
+ ? view.applicationIdle === true
311
+ ? "> 无执行中任务,可安全关闭;也可 **Fork** 后继续发送消息。"
312
+ : view.applicationIdle === false
313
+ ? "> 有任务执行中,关闭会中断任务;可 **Fork** 后继续发送消息。"
314
+ : "> 可 **Fork** 后继续发送消息;关闭进程后请重发消息。"
315
+ : "> 可 **Fork** 后继续发送消息;或手动关闭占用任务。",
316
+ ].filter((line): line is string => Boolean(line));
317
+ const elements: Record<string, unknown>[] = [markdown(lines.join("\n"))];
318
+ const canClose = owner?.canClose
319
+ && Boolean(owner.writerStartedAt)
320
+ && Boolean(owner.applicationStartedAt);
321
+ const actions: Record<string, unknown>[] = [{
322
+ tag: "button",
323
+ text: {
324
+ tag: "plain_text",
325
+ content: "Fork",
326
+ },
327
+ type: "default",
328
+ size: "small",
329
+ behaviors: [{
330
+ type: "callback",
331
+ value: {
332
+ action: "thread_writer_fork",
333
+ contextKey: view.contextKey,
334
+ sessionId: view.sessionId,
335
+ threadId: view.threadId,
336
+ },
337
+ }],
338
+ }];
339
+ if (canClose && owner) {
340
+ actions.push({
341
+ tag: "button",
342
+ text: {
343
+ tag: "plain_text",
344
+ content: view.status === "force_required"
345
+ ? `Force ${owner.applicationPid}`
346
+ : `Close ${owner.applicationPid}`,
347
+ },
348
+ type: "danger",
349
+ size: "small",
350
+ behaviors: [{
351
+ type: "callback",
352
+ value: {
353
+ action: "thread_writer_close",
354
+ contextKey: view.contextKey,
355
+ sessionId: view.sessionId,
356
+ threadId: view.threadId,
357
+ writerPid: owner.writerPid,
358
+ writerStartedAt: owner.writerStartedAt,
359
+ applicationPid: owner.applicationPid,
360
+ applicationStartedAt: owner.applicationStartedAt,
361
+ force: view.status === "force_required",
362
+ },
363
+ }],
364
+ });
365
+ }
366
+ elements.push({
367
+ tag: "column_set",
368
+ flex_mode: "none",
369
+ horizontal_spacing: "8px",
370
+ vertical_align: "center",
371
+ columns: actions.map((button) => ({
372
+ tag: "column",
373
+ width: "auto",
374
+ vertical_align: "center",
375
+ elements: [button],
376
+ })),
377
+ });
378
+ return sectionCard(
379
+ view.status === "force_required" ? "仍被占用" : "任务被占用",
380
+ elements,
381
+ view.status === "force_required" ? "red" : "orange",
382
+ );
383
+ }
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
+
265
470
  renderShellCommandCard(view: ShellCommandCardView): Record<string, unknown> {
266
471
  const rawOutput = normalizeTerminalOutput(view.output).trimEnd();
267
472
  const displayOutput = truncateMiddle(
@@ -164,9 +164,13 @@ export class FeishuTurnPresenter {
164
164
  }
165
165
  }
166
166
 
167
- async failPendingTurn(sessionId: string, message: string): Promise<void> {
167
+ async failPendingTurn(
168
+ sessionId: string,
169
+ message: string,
170
+ replacementCard?: Record<string, unknown>,
171
+ ): Promise<boolean> {
168
172
  const entry = this.pendingEntries.get(sessionId);
169
- if (!entry) return;
173
+ if (!entry) return false;
170
174
  this.pendingEntries.delete(sessionId);
171
175
  this.stopElapsedUpdates(entry);
172
176
  const completedAt = Date.now();
@@ -183,9 +187,18 @@ export class FeishuTurnPresenter {
183
187
  await entry.scheduler?.flush(entry.state);
184
188
  } catch (error) {
185
189
  this.options.onError?.(error);
186
- return;
190
+ return false;
191
+ }
192
+ if (replacementCard && entry.messageId) {
193
+ try {
194
+ await this.outbound.updateInteractiveCard(entry.messageId, replacementCard);
195
+ } catch (error) {
196
+ this.options.onError?.(error);
197
+ return false;
198
+ }
187
199
  }
188
200
  await this.syncThinkingCardReaction(entry);
201
+ return Boolean(replacementCard && entry.messageId);
189
202
  }
190
203
 
191
204
  async interruptTurnForRecovery(
@@ -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") {