lark-remote 0.1.5 → 0.1.6

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 (55) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.en.md +0 -4
  3. package/README.md +0 -4
  4. package/dist/bridge/approval-coordinator.d.ts +108 -0
  5. package/dist/bridge/approval-coordinator.js +258 -0
  6. package/dist/bridge/index.d.ts +62 -0
  7. package/dist/bridge/index.js +402 -21
  8. package/dist/bridge/queue-manager.d.ts +9 -0
  9. package/dist/bridge/queue-manager.js +19 -11
  10. package/dist/card/approval-render.d.ts +16 -0
  11. package/dist/card/approval-render.js +247 -0
  12. package/dist/card/card-shared.d.ts +14 -0
  13. package/dist/card/card-shared.js +28 -0
  14. package/dist/card/run-renderer.js +77 -6
  15. package/dist/card/run-state.d.ts +25 -3
  16. package/dist/card/run-state.js +203 -2
  17. package/dist/config/dir.js +1 -1
  18. package/dist/config/index.d.ts +42 -0
  19. package/dist/config/index.js +23 -1
  20. package/dist/connector/index.d.ts +5 -0
  21. package/dist/connector/index.js +12 -0
  22. package/dist/index.js +44 -22
  23. package/dist/router/config/codex.d.ts +1 -1
  24. package/dist/router/config/codex.js +49 -0
  25. package/dist/router/config/common/render.js +4 -0
  26. package/dist/router/config/types.d.ts +1 -1
  27. package/dist/router/index.d.ts +27 -2
  28. package/dist/router/index.js +109 -28
  29. package/dist/router/utils.d.ts +2 -0
  30. package/dist/router/utils.js +5 -2
  31. package/dist/runner/codex/app-server/client.d.ts +82 -0
  32. package/dist/runner/codex/app-server/client.js +234 -0
  33. package/dist/runner/codex/app-server/connection-manager.d.ts +56 -0
  34. package/dist/runner/codex/app-server/connection-manager.js +170 -0
  35. package/dist/runner/codex/app-server/protocol-types.d.ts +527 -0
  36. package/dist/runner/codex/app-server/protocol-types.js +67 -0
  37. package/dist/runner/codex/app-server/runner.d.ts +121 -0
  38. package/dist/runner/codex/app-server/runner.js +554 -0
  39. package/dist/runner/codex/app-server/translator.d.ts +109 -0
  40. package/dist/runner/codex/app-server/translator.js +734 -0
  41. package/dist/runner/codex/app-server/transport.d.ts +49 -0
  42. package/dist/runner/codex/app-server/transport.js +231 -0
  43. package/dist/runner/codex/factory.d.ts +29 -0
  44. package/dist/runner/codex/factory.js +39 -0
  45. package/dist/runner/codex/index.d.ts +2 -0
  46. package/dist/runner/codex/index.js +2 -0
  47. package/dist/runner/codex/runner.d.ts +3 -0
  48. package/dist/runner/codex/runner.js +5 -0
  49. package/dist/runner/common/usage.d.ts +2 -0
  50. package/dist/runner/common/usage.js +2 -0
  51. package/dist/runner/index.d.ts +2 -2
  52. package/dist/runner/index.js +1 -1
  53. package/dist/runner/types.d.ts +123 -2
  54. package/dist/session/codex/rollout-reader.js +43 -0
  55. package/package.json +1 -1
@@ -60,6 +60,18 @@ export interface CardActionPayload {
60
60
  /** CardKit 2.0 input 组件自带提交图标触发回调时回传的输入值 */
61
61
  inputValue?: string;
62
62
  orderId?: string;
63
+ /** Approval request ID for approval card actions. */
64
+ requestId?: number | string;
65
+ /** Decision for approval.respond (e.g. 'accept', 'decline', 'cancel'). */
66
+ decision?: string;
67
+ /** Scope for acceptForSession: 'turn' | 'session'. */
68
+ scope?: 'turn' | 'session';
69
+ /** Unique nonce to prevent duplicate processing. */
70
+ nonce?: string;
71
+ /** Permission item ID for approval.toggle. */
72
+ permId?: string;
73
+ /** Desired selection state for approval.toggle (card renders !current). */
74
+ selected?: boolean;
63
75
  }
64
76
  /**
65
77
  * Response a cardAction handler may return to give the clicking user native
@@ -173,6 +185,12 @@ export declare class CommandRouter {
173
185
  * Validates payload and dispatches to the corresponding command.
174
186
  */
175
187
  handleCardAction(value: CardActionPayload, ctx: CommandContext): Promise<CardActionResponse | void>;
188
+ /**
189
+ * Handle approval-related card actions.
190
+ * Routes to the appropriate bridge method based on value.cmd.
191
+ * Returns a toast response for immediate user feedback.
192
+ */
193
+ private handleApprovalAction;
176
194
  /**
177
195
  * Handle queue.cancel: remove this message from the queue.
178
196
  */
@@ -341,8 +359,7 @@ export declare class CommandRouter {
341
359
  * /update: check for newer version and upgrade if available.
342
360
  * /update check: only check, don't upgrade.
343
361
  *
344
- * Flow: check version → if newer, install → spawn replacement bridge → exit.
345
- * Reuses /restart's restartSpawner + pendingExit mechanism.
362
+ * Flow: check version → if newer, install → report success (no auto-restart).
346
363
  */
347
364
  private cmdUpdate;
348
365
  private cmdNew;
@@ -365,6 +382,14 @@ export declare class CommandRouter {
365
382
  * When the session was cleared, renders a compact notice card instead.
366
383
  */
367
384
  private buildConfigSwitchCard;
385
+ /**
386
+ * Whether a session of the given agent kind can be compacted from a resume
387
+ * card. Compact (thread/compact/start) is only implemented by the codex
388
+ * app-server runner (`runCompact`), so the button is gated on
389
+ * `serviceMode === 'app-server'` — exec mode spawns a fresh CLI per message
390
+ * and has no persistent session to compact.
391
+ */
392
+ private canCompactSession;
368
393
  private static readonly LS_PAGE_SIZE;
369
394
  private cmdLs;
370
395
  private cmdWs;
@@ -2,7 +2,7 @@ import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import YAML from 'yaml';
5
- import { getConfigDir, getConfigValue, setConfigValue, setConfigValues, mapAgentKey, assertSafeKeyPart, } from '../config/index.js';
5
+ import { getConfigDir, getConfigValue, setConfigValue, setConfigValues, getAgentConfig, mapAgentKey, assertSafeKeyPart, } from '../config/index.js';
6
6
  import { syncAgentChoices } from '../runner/index.js';
7
7
  import { WorkspaceStore } from '../workspace/index.js';
8
8
  import { OrderStore } from '../order/index.js';
@@ -45,7 +45,7 @@ const RESUME_CONTENT_PREFETCH = 5;
45
45
  * `New Session`、空串),都不是真实输入,不得渲染成行标题。
46
46
  */
47
47
  const RESUME_SUMMARY_PLACEHOLDERS = new Set(['', '(no user message)', '(无摘要)', 'New Session']);
48
- import { newSessionButton, agentDisplayName } from '../card/card-shared.js';
48
+ import { newSessionButton, resumeCompactButton, agentDisplayName } from '../card/card-shared.js';
49
49
  /**
50
50
  * Card-action commands that must run with enqueue({ immediate: true }).
51
51
  * These bypass the serial queue and execute immediately.
@@ -96,7 +96,12 @@ export function isImmediateAction(cmd) {
96
96
  cmd === 'config.toggle' ||
97
97
  cmd === 'config.set' ||
98
98
  cmd === 'config.input' ||
99
- cmd === 'config.save');
99
+ cmd === 'config.save' ||
100
+ // 审批响应/权限切换必须即时触达在途 run(同 stop 类控制动作)。若走串行
101
+ // 队列会排在等待审批的 run 之后形成死锁:run 不结束审批不执行,run 结束
102
+ // coordinator 已删响应空转(线上复现:approval.respond 排队卡、审批永不生效)。
103
+ cmd === 'approval.respond' ||
104
+ cmd === 'approval.toggle');
100
105
  }
101
106
  export class CommandRouter {
102
107
  /** Valid agent kinds — single source of truth for resume.use / resume.page / cmdResume. */
@@ -237,12 +242,12 @@ export class CommandRouter {
237
242
  return;
238
243
  case 'ls.page':
239
244
  return this.handleLsPage(value, ctx);
245
+ case 'ws.page':
246
+ return this.handleWsPage(value, ctx);
240
247
  case 'ws.use':
241
248
  return await this.handleWsUse(value, ctx);
242
249
  case 'ws.sort':
243
250
  return await this.handleWsSort(value, ctx);
244
- case 'ws.page':
245
- return await this.handleWsPage(value, ctx);
246
251
  case 'ws.remove':
247
252
  return await this.handleWsRemove(value, ctx);
248
253
  case 'resume.use': {
@@ -319,6 +324,15 @@ export class CommandRouter {
319
324
  // 调 updateCardInPlace → Feishu API 乱序到达导致 toggle 卡死。
320
325
  // 2026-07-18: 返回 enqueueConfigAction 的结果以支持 toast 响应
321
326
  return this.enqueueConfigAction(value, ctx);
327
+ case 'approval.respond':
328
+ case 'approval.toggle':
329
+ return this.handleApprovalAction(value, ctx);
330
+ case 'codex.compact':
331
+ await this.bridge.handleCodexCompact(value, ctx);
332
+ return;
333
+ case 'resume.compact':
334
+ await this.bridge.handleResumeCompact(value, ctx);
335
+ return;
322
336
  default: {
323
337
  // Help card buttons: help.<cmd> → execute /<cmd>
324
338
  if (value.cmd.startsWith('help.')) {
@@ -344,6 +358,57 @@ export class CommandRouter {
344
358
  }
345
359
  }
346
360
  }
361
+ /**
362
+ * Handle approval-related card actions.
363
+ * Routes to the appropriate bridge method based on value.cmd.
364
+ * Returns a toast response for immediate user feedback.
365
+ */
366
+ async handleApprovalAction(value, _ctx) {
367
+ const { runId, requestId, decision, scope, nonce, permId } = value;
368
+ if (value.cmd === 'approval.respond') {
369
+ if (!runId || requestId === undefined || !decision || !nonce) {
370
+ return { toast: { type: 'error', content: '缺少审批响应参数' } };
371
+ }
372
+ try {
373
+ await this.bridge.handleApprovalRespond({
374
+ runId,
375
+ requestId,
376
+ decision,
377
+ scope,
378
+ nonce,
379
+ });
380
+ return { toast: { type: 'success', content: '审批已提交' } };
381
+ }
382
+ catch (err) {
383
+ const msg = err instanceof Error ? err.message : String(err);
384
+ // 审批已过期:给用户明确反馈,不静默不误导(2026-08-12 事故:点了允许无任何反馈)。
385
+ const content = /state=expired/.test(msg)
386
+ ? '⏰ 审批已过期,无法响应'
387
+ : `审批响应失败:${msg}`;
388
+ return { toast: { type: 'error', content } };
389
+ }
390
+ }
391
+ if (value.cmd === 'approval.toggle') {
392
+ if (!runId || requestId === undefined || !permId || !nonce) {
393
+ return { toast: { type: 'error', content: '缺少权限切换参数' } };
394
+ }
395
+ try {
396
+ await this.bridge.handleApprovalToggle({
397
+ runId,
398
+ requestId,
399
+ permId,
400
+ nonce,
401
+ selected: value.selected ?? true,
402
+ });
403
+ return { toast: { type: 'info', content: '已切换' } };
404
+ }
405
+ catch (err) {
406
+ const msg = err instanceof Error ? err.message : String(err);
407
+ return { toast: { type: 'error', content: `权限切换失败:${msg}` } };
408
+ }
409
+ }
410
+ return { toast: { type: 'error', content: '未知的审批操作' } };
411
+ }
347
412
  /**
348
413
  * Handle queue.cancel: remove this message from the queue.
349
414
  */
@@ -984,9 +1049,19 @@ export class CommandRouter {
984
1049
  if (useResult.text && !useResult.text.includes('已切换')) {
985
1050
  return { toast: { type: 'error', content: useResult.text } };
986
1051
  }
987
- // Success: if auto-resume produced a card, send it as a reply
988
- if (useResult.card) {
989
- await this.bridge.sendResult(useResult, ctx);
1052
+ // Success: send auto-resume card, or a persistent text confirmation when
1053
+ // there is no history session. Toast alone is insufficient: for this
1054
+ // immediate action the callback response is swallowed by enqueueImmediate
1055
+ // (fire-and-forget), and even if delivered it is transient — the user gets
1056
+ // no perceivable feedback on the switch. Same rationale as config.save
1057
+ // agent-switch notice: persistent message first, toast only as a
1058
+ // fallback if sendResult fails.
1059
+ let fallbackToast;
1060
+ if (useResult.card || useResult.text) {
1061
+ const sent = await this.bridge.sendResult(useResult, ctx);
1062
+ if (!sent && useResult.text) {
1063
+ fallbackToast = useResult.text;
1064
+ }
990
1065
  }
991
1066
  // Refresh the /ws list card in place so "recent" sort is immediately visible
992
1067
  const currentOffset = Math.max(0, Math.trunc(Number(value.offset) || 0));
@@ -994,11 +1069,11 @@ export class CommandRouter {
994
1069
  if (refreshed.card) {
995
1070
  await this.bridge.updateCardInPlace(refreshed.card, ctx);
996
1071
  }
997
- // Toast feedback (suppress text when auto-resume card was already sent)
1072
+ // Toast feedback (suppress text when a persistent message was already sent)
998
1073
  return {
999
1074
  toast: {
1000
1075
  type: 'success',
1001
- content: useResult.card ? '' : (useResult.text ?? `已切换到 workspace "${name}"`),
1076
+ content: fallbackToast ?? '',
1002
1077
  },
1003
1078
  };
1004
1079
  }
@@ -1667,8 +1742,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
1667
1742
  * /update: check for newer version and upgrade if available.
1668
1743
  * /update check: only check, don't upgrade.
1669
1744
  *
1670
- * Flow: check version → if newer, install → spawn replacement bridge → exit.
1671
- * Reuses /restart's restartSpawner + pendingExit mechanism.
1745
+ * Flow: check version → if newer, install → report success (no auto-restart).
1672
1746
  */
1673
1747
  async cmdUpdate(args, ctx) {
1674
1748
  const checkOnly = args[0] === 'check';
@@ -1702,21 +1776,8 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
1702
1776
  if (!installResult.success) {
1703
1777
  return { text: `❌ 升级失败: ${installResult.error}` };
1704
1778
  }
1705
- // 4. Restart (reuse /restart's restartSpawner + pendingExit)
1706
- if (!this.restartSpawner) {
1707
- return { text: `✅ 已升级到 ${latest},请手动 /restart 重启 bridge` };
1708
- }
1709
- try {
1710
- const pid = this.restartSpawner();
1711
- this.pendingExit = true;
1712
- return { text: `✅ 升级成功 (${current} → ${latest}),正在重启(新进程 pid ${pid})…` };
1713
- }
1714
- catch (err) {
1715
- getLogger().error('[router] update restart spawn failed:', err);
1716
- return {
1717
- text: `✅ 已升级到 ${latest},但重启失败:${err.message},请手动 /restart`,
1718
- };
1719
- }
1779
+ // 4. Report success (no auto-restart — user decides when to /restart)
1780
+ return { text: `✅ 升级成功 (${current} → ${latest}),发送 /restart 重启后生效` };
1720
1781
  }
1721
1782
  cmdNew(ctx) {
1722
1783
  // 只清 sessionId 保留 cwd — 否则 /new 之后 /resume 会提示"请先 /cd
@@ -1876,6 +1937,11 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
1876
1937
  ],
1877
1938
  });
1878
1939
  }
1940
+ // Compact 按钮:仅 codex app-server 模式(runner 有 runCompact)且会话未在跑。
1941
+ // 在途 run 会占住 app-server runner,压缩必然失败,此时不渲染。
1942
+ if (!isActive && this.canCompactSession(this.config.defaultAgent)) {
1943
+ actions.push(resumeCompactButton(session.sessionId, this.config.defaultAgent));
1944
+ }
1879
1945
  // New session button always shown (use shared function for consistency)
1880
1946
  actions.push(newSessionButton());
1881
1947
  // Use column_set+column for 2.0 (action tag not supported in 2.0)
@@ -2030,6 +2096,16 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2030
2096
  },
2031
2097
  };
2032
2098
  }
2099
+ /**
2100
+ * Whether a session of the given agent kind can be compacted from a resume
2101
+ * card. Compact (thread/compact/start) is only implemented by the codex
2102
+ * app-server runner (`runCompact`), so the button is gated on
2103
+ * `serviceMode === 'app-server'` — exec mode spawns a fresh CLI per message
2104
+ * and has no persistent session to compact.
2105
+ */
2106
+ canCompactSession(agentKind) {
2107
+ return (agentKind === 'codex' && getAgentConfig(this.config, 'codex')?.serviceMode === 'app-server');
2108
+ }
2033
2109
  static LS_PAGE_SIZE = 30;
2034
2110
  cmdLs(args, ctx, offset = 0) {
2035
2111
  const cwd = this.sessionStore.getCwd(ctx.userId);
@@ -2551,7 +2627,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2551
2627
  }
2552
2628
  const card = {
2553
2629
  schema: '2.0',
2554
- config: { wide_screen_mode: true },
2630
+ config: { wide_screen_mode: true, update_multi: true },
2555
2631
  header: { title: { tag: 'plain_text', content: 'Workspaces' } },
2556
2632
  body: { elements: bodyElements },
2557
2633
  };
@@ -2666,6 +2742,11 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2666
2742
  behaviors: [{ type: 'callback', value: { cmd: 'stop', runId: activeRunRunId, cwd } }],
2667
2743
  });
2668
2744
  }
2745
+ // Compact 按钮:仅 codex app-server 模式(runner 有 runCompact)且会话未在跑。
2746
+ // agentKind 来自 /resume [agent] <id> / resume.use 卡片值,非 defaultAgent。
2747
+ if (!isActive && this.canCompactSession(agentKind)) {
2748
+ buttons.push(resumeCompactButton(sessionIdArg, agentKind));
2749
+ }
2669
2750
  if (buttons.length > 0) {
2670
2751
  elements.push({
2671
2752
  tag: 'column_set',
@@ -22,6 +22,8 @@ export declare function formatUsageStats(usage?: {
22
22
  contextLength?: number;
23
23
  contextLimit?: number;
24
24
  compactCount?: number;
25
+ /** 压缩前上下文水位;与 contextLength(压缩后)成对出现在 Compact 卡上。 */
26
+ compactPreContextLength?: number;
25
27
  cacheReadTokens?: number;
26
28
  cacheCreationTokens?: number;
27
29
  /** Agent-declared total; when present the display uses max(total, sum of parts). */
@@ -46,13 +46,16 @@ export function formatUsageStats(usage, options) {
46
46
  if (options?.showResult && options?.result) {
47
47
  lines.push(`结果 - ${options.result}`);
48
48
  }
49
- // 2. Context 长度
49
+ // 2. Context 长度(compact 卡同时展示压缩前水位)
50
50
  if (usage?.contextLength !== undefined) {
51
51
  const ctxLimit = usage.contextLimit;
52
52
  const ctxPercent = ctxLimit !== undefined && ctxLimit > 0
53
53
  ? ` (${Math.round((usage.contextLength / ctxLimit) * 100)}%)`
54
54
  : '';
55
- lines.push(`Context - ${formatTokenK(usage.contextLength)}${ctxPercent}`);
55
+ const preCompact = usage.compactPreContextLength !== undefined
56
+ ? `(压缩前 ${formatTokenK(usage.compactPreContextLength)})`
57
+ : '';
58
+ lines.push(`Context - ${formatTokenK(usage.contextLength)}${ctxPercent}${preCompact}`);
56
59
  }
57
60
  // 3. Compact 次数
58
61
  if (usage?.compactCount) {
@@ -0,0 +1,82 @@
1
+ /**
2
+ * JSON-RPC client for the Codex App Server protocol.
3
+ *
4
+ * Manages the handshake, request/response matching, notification dispatch,
5
+ * and timeout handling over a JsonlRpcTransport.
6
+ */
7
+ import type { JsonlRpcTransport } from './transport.js';
8
+ import { type InitializeResult } from './protocol-types.js';
9
+ export declare class RpcError extends Error {
10
+ readonly code: number;
11
+ readonly data?: unknown;
12
+ constructor(code: number, message: string, data?: unknown);
13
+ }
14
+ export declare class RpcTimeoutError extends Error {
15
+ constructor(message: string);
16
+ }
17
+ export declare class ConnectionLostError extends Error {
18
+ constructor(message?: string);
19
+ }
20
+ export interface ClientHooks {
21
+ onNotification(method: string, params: unknown): void;
22
+ onServerRequest(id: number | string, method: string, params: unknown): void;
23
+ onClose(): void;
24
+ }
25
+ export declare class CodexAppServerClient {
26
+ private transport;
27
+ /**
28
+ * Lifecycle hooks owned by the connection layer (ConnectionManager): slot
29
+ * cleanup + onConnectionLost. These must survive runner's setHooks().
30
+ */
31
+ private baseHooks;
32
+ /** Per-run hooks from the runner, layered over baseHooks via setHooks(). */
33
+ private runHooks;
34
+ private requestTimeoutMs;
35
+ private pendingRequests;
36
+ private nextId;
37
+ private _ready;
38
+ private _disposed;
39
+ constructor(transport: JsonlRpcTransport, hooks: ClientHooks, requestTimeoutMs?: number);
40
+ /**
41
+ * Replace the per-run hooks (used by the runner to bridge
42
+ * notifications/server requests into the active turn's translator).
43
+ * Connection-layer hooks (slot cleanup / onConnectionLost) are preserved:
44
+ * review P2-1 — an overwrite here previously silenced onClose slot cleanup,
45
+ * leaving dead slots behind after the app-server process was killed.
46
+ */
47
+ setHooks(hooks: ClientHooks): void;
48
+ get ready(): boolean;
49
+ /**
50
+ * Whether the underlying transport is still alive (not disposed and not
51
+ * closed). Used by the connection manager to avoid handing out a cached
52
+ * client whose child process was killed externally.
53
+ */
54
+ get healthy(): boolean;
55
+ /**
56
+ * Connect and perform the initialize handshake.
57
+ * Returns the InitializeResult on success.
58
+ */
59
+ connect(): Promise<InitializeResult>;
60
+ /**
61
+ * Send a JSON-RPC request and wait for the response.
62
+ */
63
+ request<P, R>(method: string, params?: P): Promise<R>;
64
+ /**
65
+ * Send a JSON-RPC notification (no response expected).
66
+ */
67
+ notify(method: string, params?: unknown): void;
68
+ /**
69
+ * Respond to a server request (success).
70
+ */
71
+ respond(id: number | string, result: unknown): void;
72
+ /**
73
+ * Respond to a server request (error).
74
+ */
75
+ respondError(id: number | string, code: number, message: string): void;
76
+ /**
77
+ * Dispose the client: cancel pending requests and close transport.
78
+ */
79
+ dispose(): Promise<void>;
80
+ private handleMessage;
81
+ private failPending;
82
+ }
@@ -0,0 +1,234 @@
1
+ /**
2
+ * JSON-RPC client for the Codex App Server protocol.
3
+ *
4
+ * Manages the handshake, request/response matching, notification dispatch,
5
+ * and timeout handling over a JsonlRpcTransport.
6
+ */
7
+ import { getLogger } from '../../../logger/index.js';
8
+ // =============================================================================
9
+ // Error Types
10
+ // =============================================================================
11
+ export class RpcError extends Error {
12
+ code;
13
+ data;
14
+ constructor(code, message, data) {
15
+ super(message);
16
+ this.name = 'RpcError';
17
+ this.code = code;
18
+ this.data = data;
19
+ }
20
+ }
21
+ export class RpcTimeoutError extends Error {
22
+ constructor(message) {
23
+ super(message);
24
+ this.name = 'RpcTimeoutError';
25
+ }
26
+ }
27
+ export class ConnectionLostError extends Error {
28
+ constructor(message = 'Connection lost') {
29
+ super(message);
30
+ this.name = 'ConnectionLostError';
31
+ }
32
+ }
33
+ // =============================================================================
34
+ // Client
35
+ // =============================================================================
36
+ export class CodexAppServerClient {
37
+ transport;
38
+ /**
39
+ * Lifecycle hooks owned by the connection layer (ConnectionManager): slot
40
+ * cleanup + onConnectionLost. These must survive runner's setHooks().
41
+ */
42
+ baseHooks;
43
+ /** Per-run hooks from the runner, layered over baseHooks via setHooks(). */
44
+ runHooks = null;
45
+ requestTimeoutMs;
46
+ pendingRequests = new Map();
47
+ nextId = 1;
48
+ _ready = false;
49
+ _disposed = false;
50
+ constructor(transport, hooks, requestTimeoutMs = 60_000) {
51
+ this.transport = transport;
52
+ this.baseHooks = hooks;
53
+ this.requestTimeoutMs = requestTimeoutMs;
54
+ }
55
+ /**
56
+ * Replace the per-run hooks (used by the runner to bridge
57
+ * notifications/server requests into the active turn's translator).
58
+ * Connection-layer hooks (slot cleanup / onConnectionLost) are preserved:
59
+ * review P2-1 — an overwrite here previously silenced onClose slot cleanup,
60
+ * leaving dead slots behind after the app-server process was killed.
61
+ */
62
+ setHooks(hooks) {
63
+ this.runHooks = hooks;
64
+ }
65
+ get ready() {
66
+ return this._ready;
67
+ }
68
+ /**
69
+ * Whether the underlying transport is still alive (not disposed and not
70
+ * closed). Used by the connection manager to avoid handing out a cached
71
+ * client whose child process was killed externally.
72
+ */
73
+ get healthy() {
74
+ return !this._disposed && !this.transport.closed;
75
+ }
76
+ /**
77
+ * Connect and perform the initialize handshake.
78
+ * Returns the InitializeResult on success.
79
+ */
80
+ async connect() {
81
+ const events = {
82
+ onMessage: (msg) => this.handleMessage(msg),
83
+ onClose: () => {
84
+ this.failPending(new ConnectionLostError('transport closed'));
85
+ // 先通知连接层(slot 清理 + onConnectionLost),再通知当前 run 层
86
+ // (failTurn 等)。两层互不覆盖。
87
+ this.baseHooks.onClose();
88
+ this.runHooks?.onClose();
89
+ },
90
+ };
91
+ await this.transport.start(events);
92
+ // ENOENT/启动失败时 transport 已在 start() 内触发 onClose 并置 closed;
93
+ // 直接 fail-fast,避免 initialize 请求挂满 requestTimeoutMs 才报错。
94
+ if (this.transport.closed) {
95
+ throw new ConnectionLostError('codex app-server failed to start (binary missing or crashed)');
96
+ }
97
+ // Send initialize request
98
+ const initParams = {
99
+ clientInfo: {
100
+ name: 'lark-remote',
101
+ version: '1.0.0',
102
+ },
103
+ };
104
+ const result = await this.request('initialize', initParams);
105
+ this._ready = true;
106
+ return result;
107
+ }
108
+ /**
109
+ * Send a JSON-RPC request and wait for the response.
110
+ */
111
+ async request(method, params) {
112
+ if (this._disposed) {
113
+ throw new ConnectionLostError('client is disposed');
114
+ }
115
+ const id = this.nextId++;
116
+ const request = {
117
+ jsonrpc: '2.0',
118
+ id,
119
+ method,
120
+ params,
121
+ };
122
+ return new Promise((resolve, reject) => {
123
+ const timer = setTimeout(() => {
124
+ this.pendingRequests.delete(id);
125
+ reject(new RpcTimeoutError(`request timed out: ${method}`));
126
+ }, this.requestTimeoutMs);
127
+ this.pendingRequests.set(id, {
128
+ resolve: resolve,
129
+ reject,
130
+ timer,
131
+ });
132
+ this.transport.write(request);
133
+ });
134
+ }
135
+ /**
136
+ * Send a JSON-RPC notification (no response expected).
137
+ */
138
+ notify(method, params) {
139
+ if (this._disposed)
140
+ return;
141
+ const notification = {
142
+ jsonrpc: '2.0',
143
+ method,
144
+ params,
145
+ };
146
+ this.transport.write(notification);
147
+ }
148
+ /**
149
+ * Respond to a server request (success).
150
+ */
151
+ respond(id, result) {
152
+ const response = {
153
+ jsonrpc: '2.0',
154
+ id,
155
+ result,
156
+ };
157
+ this.transport.write(response);
158
+ }
159
+ /**
160
+ * Respond to a server request (error).
161
+ */
162
+ respondError(id, code, message) {
163
+ const response = {
164
+ jsonrpc: '2.0',
165
+ id,
166
+ error: { code, message },
167
+ };
168
+ this.transport.write(response);
169
+ }
170
+ /**
171
+ * Dispose the client: cancel pending requests and close transport.
172
+ */
173
+ async dispose() {
174
+ if (this._disposed)
175
+ return;
176
+ this._disposed = true;
177
+ this._ready = false;
178
+ this.failPending(new ConnectionLostError('client disposed'));
179
+ await this.transport.close();
180
+ }
181
+ // =========================================================================
182
+ // Internal
183
+ // =========================================================================
184
+ handleMessage(msg) {
185
+ const message = msg;
186
+ if ('id' in message && 'result' in message) {
187
+ // Success response
188
+ const id = message.id;
189
+ const pending = this.pendingRequests.get(id);
190
+ if (pending) {
191
+ clearTimeout(pending.timer);
192
+ this.pendingRequests.delete(id);
193
+ pending.resolve(message.result);
194
+ }
195
+ return;
196
+ }
197
+ if ('id' in message && 'error' in message) {
198
+ // Error response
199
+ const id = message.id;
200
+ const err = message.error;
201
+ const pending = this.pendingRequests.get(id);
202
+ if (pending) {
203
+ clearTimeout(pending.timer);
204
+ this.pendingRequests.delete(id);
205
+ pending.reject(new RpcError(err.code, err.message, err.data));
206
+ }
207
+ return;
208
+ }
209
+ if ('method' in message && !('id' in message)) {
210
+ // Notification
211
+ const method = message.method;
212
+ this.baseHooks.onNotification(method, message.params);
213
+ this.runHooks?.onNotification(method, message.params);
214
+ return;
215
+ }
216
+ if ('method' in message && 'id' in message) {
217
+ // Server request (incoming request from server)
218
+ const id = message.id;
219
+ const method = message.method;
220
+ this.baseHooks.onServerRequest(id, method, message.params);
221
+ this.runHooks?.onServerRequest(id, method, message.params);
222
+ return;
223
+ }
224
+ getLogger().warn(`[codex-client] unknown message type: ${JSON.stringify(message).slice(0, 200)}`);
225
+ }
226
+ failPending(err) {
227
+ for (const [_id, pending] of this.pendingRequests) {
228
+ clearTimeout(pending.timer);
229
+ pending.reject(err);
230
+ }
231
+ this.pendingRequests.clear();
232
+ }
233
+ }
234
+ //# sourceMappingURL=client.js.map