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
@@ -32,8 +32,204 @@ export function reduceRunState(state, event) {
32
32
  return reducePlanEvent(state, event);
33
33
  if (event.type === 'file_change')
34
34
  return reduceFileChangeEvent(state, event);
35
+ if (event.type === 'turn_started')
36
+ return reduceTurnStartedEvent(state, event);
37
+ if (event.type === 'turn_diff')
38
+ return reduceTurnDiffEvent(state, event);
39
+ if (event.type === 'approval_requested') {
40
+ const approvals = state.approvals ?? [];
41
+ const next = { view: event.view, expired: false };
42
+ const idx = approvals.findIndex((a) => a.view.requestId === event.requestId);
43
+ if (idx >= 0) {
44
+ // 同一 requestId 重复到达:原地替换,保持槽位顺序。
45
+ return {
46
+ ...state,
47
+ approvals: approvals.map((a, i) => (i === idx ? next : a)),
48
+ };
49
+ }
50
+ return { ...state, approvals: [...approvals, next] };
51
+ }
52
+ if (event.type === 'approval_resolved') {
53
+ return {
54
+ ...state,
55
+ approvals: (state.approvals ?? []).filter((a) => a.view.requestId !== event.requestId),
56
+ };
57
+ }
58
+ if (event.type === 'approval_view_updated') {
59
+ return {
60
+ ...state,
61
+ approvals: (state.approvals ?? []).map((a) => a.view.requestId === event.requestId ? { ...a, view: event.view } : a),
62
+ };
63
+ }
64
+ if (event.type === 'approval_expired') {
65
+ return {
66
+ ...state,
67
+ approvals: (state.approvals ?? []).map((a) => a.view.requestId === event.requestId ? { ...a, expired: true } : a),
68
+ };
69
+ }
35
70
  return state;
36
71
  }
72
+ /** Handle app-server turn_started — records the operation kind (turn vs compact). */
73
+ function reduceTurnStartedEvent(state, event) {
74
+ if (event.type !== 'turn_started')
75
+ return state;
76
+ return {
77
+ ...state,
78
+ operationKind: event.operationKind,
79
+ footer: state.footer ?? 'thinking',
80
+ };
81
+ }
82
+ /**
83
+ * Handle app-server turn_diff events. The translator emits FULL per-item
84
+ * snapshots (accumulated text/reasoning/tool output/plan, current file-change
85
+ * set). Each snapshot is scoped to a thread item (event.itemId); the block is
86
+ * replaced in place to avoid duplication, and a NEW block is appended on the
87
+ * item's first sight — so interleaved items keep their real chronology and
88
+ * the active streaming content sits at the bottom, like the exec path.
89
+ */
90
+ function reduceTurnDiffEvent(state, event) {
91
+ if (event.type !== 'turn_diff')
92
+ return state;
93
+ let next = state;
94
+ const ts = event.timestamp;
95
+ const complete = event.complete === true;
96
+ if (event.text !== undefined) {
97
+ const blocks = markThinkingInactive(next.blocks);
98
+ next = {
99
+ ...next,
100
+ blocks: keepLatestBlocks(upsertSnapshotBlock(blocks, (b) => b.kind === 'text' && b.itemId === event.itemId, () => ({
101
+ kind: 'text',
102
+ itemId: event.itemId,
103
+ content: keepLatest(event.text, MAX_TEXT_CHARS),
104
+ timestamp: ts,
105
+ ...(complete ? { completedAt: ts } : {}),
106
+ }), (existing) => {
107
+ const textBlock = existing;
108
+ return {
109
+ ...textBlock,
110
+ content: keepLatest(event.text, MAX_TEXT_CHARS),
111
+ ...(complete ? { completedAt: ts } : {}),
112
+ };
113
+ }, !complete)),
114
+ footer: 'streaming',
115
+ };
116
+ }
117
+ if (event.reasoning !== undefined) {
118
+ next = {
119
+ ...next,
120
+ blocks: keepLatestBlocks(upsertSnapshotBlock(next.blocks, (b) => b.kind === 'thinking' && b.itemId === event.itemId, () => ({
121
+ kind: 'thinking',
122
+ itemId: event.itemId,
123
+ content: keepLatest(event.reasoning, MAX_REASONING_CHARS),
124
+ active: !complete,
125
+ timestamp: ts,
126
+ ...(complete ? { completedAt: ts } : {}),
127
+ }), (existing) => {
128
+ const thinkingBlock = existing;
129
+ return {
130
+ ...thinkingBlock,
131
+ content: keepLatest(event.reasoning, MAX_REASONING_CHARS),
132
+ active: !complete,
133
+ ...(complete ? { completedAt: ts } : {}),
134
+ };
135
+ }, !complete)),
136
+ footer: 'thinking',
137
+ };
138
+ }
139
+ if (event.toolOutput !== undefined) {
140
+ next = {
141
+ ...next,
142
+ blocks: keepLatestBlocks(upsertSnapshotBlock(next.blocks, (b) => b.kind === 'tool' && b.tool.id === event.itemId, () => ({
143
+ kind: 'tool',
144
+ tool: {
145
+ id: event.itemId,
146
+ name: 'command',
147
+ input: '',
148
+ output: keepLatest(event.toolOutput, MAX_TOOL_DETAIL_CHARS),
149
+ status: complete ? (event.toolStatus ?? 'ok') : 'running',
150
+ startedAt: ts,
151
+ ...(complete ? { completedAt: ts } : {}),
152
+ },
153
+ }), (existing) => ({
154
+ kind: 'tool',
155
+ tool: {
156
+ ...existing.tool,
157
+ output: keepLatest(event.toolOutput, MAX_TOOL_DETAIL_CHARS),
158
+ status: complete
159
+ ? (event.toolStatus ?? 'ok')
160
+ : existing.tool.status,
161
+ ...(complete ? { completedAt: ts } : {}),
162
+ },
163
+ }), !complete)),
164
+ footer: 'tool_running',
165
+ };
166
+ }
167
+ if (event.plan !== undefined) {
168
+ const newPlan = keepLatest(event.plan, MAX_REASONING_CHARS * 2);
169
+ next = {
170
+ ...next,
171
+ plan: newPlan,
172
+ blocks: keepLatestBlocks(upsertSnapshotBlock(next.blocks, (b) => b.kind === 'plan' && b.itemId === event.itemId, () => ({
173
+ kind: 'plan',
174
+ itemId: event.itemId,
175
+ content: newPlan,
176
+ active: !complete,
177
+ timestamp: ts,
178
+ ...(complete ? { completedAt: ts } : {}),
179
+ }), (existing) => {
180
+ const planBlock = existing;
181
+ return {
182
+ ...planBlock,
183
+ content: newPlan,
184
+ active: !complete,
185
+ ...(complete ? { completedAt: ts } : {}),
186
+ };
187
+ }, !complete)),
188
+ };
189
+ }
190
+ if (event.fileChanges !== undefined) {
191
+ let blocks = next.blocks;
192
+ for (const fc of event.fileChanges) {
193
+ const fileBlock = {
194
+ kind: 'file_change',
195
+ itemId: event.itemId,
196
+ path: fc.path,
197
+ operation: fc.kind === 'add' ? 'create' : fc.kind === 'delete' ? 'delete' : 'edit',
198
+ diff: fc.diff,
199
+ timestamp: ts,
200
+ ...(complete ? { completedAt: ts } : {}),
201
+ };
202
+ // 同一 item 的同一路径原地替换(权威 diff 校正),新路径/新 item 追加。
203
+ blocks = upsertSnapshotBlock(blocks, (b) => b.kind === 'file_change' && b.itemId === event.itemId && b.path === fc.path, () => fileBlock, () => fileBlock, !complete);
204
+ }
205
+ next = { ...next, blocks: keepLatestBlocks(blocks) };
206
+ }
207
+ return next;
208
+ }
209
+ /**
210
+ * Replace the last block matching `predicate` with `make`; if none matches,
211
+ * append a block built by `fallback`. Snapshot semantics for app-server
212
+ * turn_diff streams.
213
+ *
214
+ * `moveToEndWhenNotLast`: 当命中块不在数组末尾且为流式更新(complete=false)
215
+ * 时,把更新后的块移到末尾。正常协议下 item 串行、流式块总是最后一个,
216
+ * 此分支不触发;若真实 server 在同一个 item 上跨其他 item 续流 delta(违反
217
+ * item 生命周期),流式内容仍会出现在卡片底部,而不是钉在旧位置。
218
+ */
219
+ function upsertSnapshotBlock(blocks, predicate, fallback, update, moveToEndWhenNotLast = false) {
220
+ for (let i = blocks.length - 1; i >= 0; i--) {
221
+ if (predicate(blocks[i])) {
222
+ const copy = blocks.slice();
223
+ copy[i] = update ? update(blocks[i]) : fallback();
224
+ if (moveToEndWhenNotLast && i < copy.length - 1) {
225
+ const [moved] = copy.splice(i, 1);
226
+ copy.push(moved);
227
+ }
228
+ return copy;
229
+ }
230
+ }
231
+ return [...blocks, fallback()];
232
+ }
37
233
  /** Handle system init and compact_boundary events. */
38
234
  function reduceSystemEvent(state, event) {
39
235
  if (event.type !== 'system')
@@ -71,8 +267,13 @@ function reduceResultEvent(state, event) {
71
267
  const resultEvent = event;
72
268
  const incomingSubtype = event.subtype;
73
269
  const incomingErrorMsg = incomingSubtype === 'error' ? (resultEvent.errorMessage ?? 'Agent 返回错误结果') : undefined;
74
- // 合并 subtype:error 优先(任一为 error 即 error)
75
- const mergedSubtype = state.resultSubtype === 'error' || incomingSubtype === 'error' ? 'error' : 'success';
270
+ // 合并 subtype:error 优先,其次 interrupted(任一为 error 即 error;
271
+ // 否则任一为 interrupted 即 interrupted;interrupted 不得并入 success)。
272
+ const mergedSubtype = state.resultSubtype === 'error' || incomingSubtype === 'error'
273
+ ? 'error'
274
+ : state.resultSubtype === 'interrupted' || incomingSubtype === 'interrupted'
275
+ ? 'interrupted'
276
+ : 'success';
76
277
  // 合并 errorMsg:首个非 undefined 胜出(existing ?? incoming)
77
278
  const mergedErrorMsg = state.errorMsg ?? incomingErrorMsg;
78
279
  return {
@@ -79,7 +79,7 @@ export function printHelp() {
79
79
  'Options:',
80
80
  ' --config-dir <path> 自定义配置目录(默认 ~/.lark-remote,可用于同机多实例)',
81
81
  ' --settings <path> 指定 Claude 配置文件路径',
82
- ' --dev 开发模式:标记从源码 bun src/index.ts 启动(空闲超时自动停止据此选择拉起方式)',
82
+ ' --dev 开发模式:标记从源码 bun src/index.ts 启动(看门狗据此选择拉起方式)',
83
83
  ' --update 升级到最新版本后退出(用于 cron/脚本自动化升级)',
84
84
  ' -h, --help 显示本帮助信息',
85
85
  ' -v, --version 显示版本号',
@@ -10,6 +10,8 @@ export declare const PI_THINKING_LEVELS: readonly ["off", "minimal", "low", "med
10
10
  * instead of re-literalizing `5000` (Clean Code P3-1, G25).
11
11
  */
12
12
  export declare const DEFAULT_STOP_GRACE_MS: 5000;
13
+ /** Codex app-server turn idle timeout default (minutes). */
14
+ export declare const DEFAULT_TURN_IDLE_TIMEOUT_MINUTES: 10;
13
15
  /** 模型 ID → alias 映射。 */
14
16
  export declare const MODEL_ID_TO_ALIAS: Record<string, string>;
15
17
  /** alias → model ID 反向映射。 */
@@ -31,6 +33,26 @@ export declare const CodexConfigSchema: z.ZodObject<{
31
33
  modelProvider: z.ZodOptional<z.ZodString>;
32
34
  reasoningEffort: z.ZodOptional<z.ZodString>;
33
35
  stopGraceMs: z.ZodDefault<z.ZodNumber>;
36
+ serviceMode: z.ZodOptional<z.ZodEnum<{
37
+ exec: "exec";
38
+ "app-server": "app-server";
39
+ }>>;
40
+ approvalPolicy: z.ZodOptional<z.ZodEnum<{
41
+ untrusted: "untrusted";
42
+ "on-request": "on-request";
43
+ never: "never";
44
+ }>>;
45
+ sandbox: z.ZodOptional<z.ZodEnum<{
46
+ "danger-full-access": "danger-full-access";
47
+ "read-only": "read-only";
48
+ "workspace-write": "workspace-write";
49
+ }>>;
50
+ appServer: z.ZodOptional<z.ZodObject<{
51
+ binary: z.ZodDefault<z.ZodString>;
52
+ requestTimeoutMs: z.ZodDefault<z.ZodNumber>;
53
+ idleTtlMs: z.ZodDefault<z.ZodNumber>;
54
+ turnIdleTimeoutMinutes: z.ZodDefault<z.ZodNumber>;
55
+ }, z.core.$strip>>;
34
56
  }, z.core.$strip>;
35
57
  /** OpenCode-specific configuration (run mode: opencode run --format json --auto). */
36
58
  declare const OpencodeConfigSchema: z.ZodObject<{
@@ -82,6 +104,26 @@ export declare const AppConfigSchema: z.ZodObject<{
82
104
  modelProvider: z.ZodOptional<z.ZodString>;
83
105
  reasoningEffort: z.ZodOptional<z.ZodString>;
84
106
  stopGraceMs: z.ZodDefault<z.ZodNumber>;
107
+ serviceMode: z.ZodOptional<z.ZodEnum<{
108
+ exec: "exec";
109
+ "app-server": "app-server";
110
+ }>>;
111
+ approvalPolicy: z.ZodOptional<z.ZodEnum<{
112
+ untrusted: "untrusted";
113
+ "on-request": "on-request";
114
+ never: "never";
115
+ }>>;
116
+ sandbox: z.ZodOptional<z.ZodEnum<{
117
+ "danger-full-access": "danger-full-access";
118
+ "read-only": "read-only";
119
+ "workspace-write": "workspace-write";
120
+ }>>;
121
+ appServer: z.ZodOptional<z.ZodObject<{
122
+ binary: z.ZodDefault<z.ZodString>;
123
+ requestTimeoutMs: z.ZodDefault<z.ZodNumber>;
124
+ idleTtlMs: z.ZodDefault<z.ZodNumber>;
125
+ turnIdleTimeoutMinutes: z.ZodDefault<z.ZodNumber>;
126
+ }, z.core.$strip>>;
85
127
  }, z.core.$strip>>;
86
128
  opencode: z.ZodOptional<z.ZodObject<{
87
129
  providerID: z.ZodDefault<z.ZodString>;
@@ -16,6 +16,7 @@ const DEFAULTS = {
16
16
  CLAUDE_MODEL: 'claude-opus-4-8',
17
17
  STOP_GRACE_MS: 5000,
18
18
  IDLE_WATCHDOG_MINUTES: 15,
19
+ TURN_IDLE_TIMEOUT_MINUTES: 10,
19
20
  };
20
21
  /**
21
22
  * Default SIGTERM→SIGKILL grace period (ms). Exported so runners (SpawningRunner)
@@ -23,6 +24,8 @@ const DEFAULTS = {
23
24
  * instead of re-literalizing `5000` (Clean Code P3-1, G25).
24
25
  */
25
26
  export const DEFAULT_STOP_GRACE_MS = DEFAULTS.STOP_GRACE_MS;
27
+ /** Codex app-server turn idle timeout default (minutes). */
28
+ export const DEFAULT_TURN_IDLE_TIMEOUT_MINUTES = DEFAULTS.TURN_IDLE_TIMEOUT_MINUTES;
26
29
  /** 模型 ID → alias 映射。 */
27
30
  export const MODEL_ID_TO_ALIAS = {
28
31
  'claude-opus-4-8': 'opus',
@@ -47,13 +50,24 @@ const ClaudeConfigSchema = z.object({
47
50
  // permissionMode 硬编码为 bypassPermissions(runner 内部),不通过 config 配置
48
51
  stopGraceMs: z.number().int().min(0).default(DEFAULTS.STOP_GRACE_MS),
49
52
  });
53
+ /** Codex app-server connection configuration. */
54
+ const CodexAppServerConfigSchema = z.object({
55
+ /** Path to the codex binary for app-server mode. */
56
+ binary: z.string().default('codex'),
57
+ /** Request timeout in milliseconds. */
58
+ requestTimeoutMs: z.number().int().min(0).default(60000),
59
+ /** Idle TTL for connection pool in milliseconds. */
60
+ idleTtlMs: z.number().int().min(0).default(1800000),
61
+ /** Turn idle timeout in minutes: no app-server output for this long triggers
62
+ * turn/interrupt and fails the run. 0 disables the timeout. */
63
+ turnIdleTimeoutMinutes: z.number().int().min(0).default(DEFAULT_TURN_IDLE_TIMEOUT_MINUTES),
64
+ });
50
65
  /** Codex-specific configuration. */
51
66
  export const CodexConfigSchema = z.object({
52
67
  /** model to use. Undefined → codex reads from its config.toml. */
53
68
  model: z.string().optional(),
54
69
  /** model provider, e.g. 'volcengine-coding-plan'. Undefined → codex reads from its config.toml. */
55
70
  modelProvider: z.string().optional(),
56
- // approvalPolicy 和 sandboxPolicy 在 runner 内硬编码(exec 模式使用 approval_policy=never)
57
71
  /**
58
72
  * Reasoning effort level。codex 标准档位 minimal/low/medium/high/xhigh/max/ultra
59
73
  * 之外还接受 none 与模型自定义值(ReasoningEffort::Custom),目录声明什么就存什么
@@ -63,6 +77,14 @@ export const CodexConfigSchema = z.object({
63
77
  reasoningEffort: z.string().min(1).optional(),
64
78
  /** Stop grace period in milliseconds. */
65
79
  stopGraceMs: z.number().int().min(0).default(5000),
80
+ /** Service mode: 'exec' (spawn-per-message) or 'app-server' (persistent connection). */
81
+ serviceMode: z.enum(['exec', 'app-server']).optional(),
82
+ /** Approval policy (Codex 官方 AskForApproval 标准值). 仅 app-server 模式有效(exec 固定 approval_policy="never")。 */
83
+ approvalPolicy: z.enum(['untrusted', 'on-request', 'never']).optional(),
84
+ /** Sandbox mode (Codex 官方 SandboxMode 标准值). 仅 app-server 模式有效(exec 固定 --sandbox danger-full-access)。 */
85
+ sandbox: z.enum(['read-only', 'workspace-write', 'danger-full-access']).optional(),
86
+ /** App-server connection configuration. */
87
+ appServer: CodexAppServerConfigSchema.optional(),
66
88
  });
67
89
  /** OpenCode-specific configuration (run mode: opencode run --format json --auto). */
68
90
  const OpencodeConfigSchema = z.object({
@@ -68,6 +68,11 @@ export declare class FeishuConnector {
68
68
  /** Format error for logging without causing circular serialization */
69
69
  private formatError;
70
70
  addReaction(messageId: string, emoji: string): Promise<void>;
71
+ /**
72
+ * Remove a reaction by emoji (e.g. retract the Typing indicator).
73
+ * The SDK resolves the reaction id internally; missing reaction is a no-op.
74
+ */
75
+ removeReactionByEmoji(messageId: string, emoji: string): Promise<void>;
71
76
  /**
72
77
  * Upload a file to Feishu and send it to the specified chat.
73
78
  * Uses the im/v1/files upload API.
@@ -264,6 +264,18 @@ export class FeishuConnector {
264
264
  getLogger().error('[feishu] addReaction failed:', this.formatError(err));
265
265
  }
266
266
  }
267
+ /**
268
+ * Remove a reaction by emoji (e.g. retract the Typing indicator).
269
+ * The SDK resolves the reaction id internally; missing reaction is a no-op.
270
+ */
271
+ async removeReactionByEmoji(messageId, emoji) {
272
+ try {
273
+ await this.channel.removeReactionByEmoji(messageId, emoji);
274
+ }
275
+ catch (err) {
276
+ getLogger().error('[feishu] removeReactionByEmoji failed:', this.formatError(err));
277
+ }
278
+ }
267
279
  /**
268
280
  * Upload a file to Feishu and send it to the specified chat.
269
281
  * Uses the im/v1/files upload API.
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import { resolveAgentChoices, } from './runner/index.js';
3
3
  import { ensureConfig } from './config/wizard.js';
4
4
  import { parseCliArgs, resolveConfigDir, setConfigDir, printHelp, printVersion, } from './config/dir.js';
5
5
  import { FeishuConnector } from './connector/index.js';
6
- import { ClaudeRunner, CodexExecRunner, OpencodeExecRunner, PiRunner, KimiRunner, } from './runner/index.js';
6
+ import { ClaudeRunner, createCodexRunner, OpencodeExecRunner, PiRunner, KimiRunner, } from './runner/index.js';
7
7
  import { AgentRegistry } from './runner/registry.js';
8
8
  import { probeAllAgents } from './runner/probe.js';
9
9
  import { SessionReaderRegistry, SessionStore } from './session/index.js';
@@ -21,11 +21,11 @@ import { classifyRejection } from './error-classification.js';
21
21
  import { WorkspaceStore } from './workspace/index.js';
22
22
  import { markdownDiv } from './card/collapsible.js';
23
23
  import { sessionEventPanel } from './router/card-helpers.js';
24
- import { newSessionButton, agentDisplayName } from './card/card-shared.js';
24
+ import { newSessionButton, resumeCompactButton, agentDisplayName } from './card/card-shared.js';
25
25
  import path from 'node:path';
26
26
  import fs from 'node:fs';
27
27
  /** Send auto-resume card for a restored session */
28
- async function sendAutoResumeCard(connector, contact, cwd, session, state) {
28
+ async function sendAutoResumeCard(connector, contact, cwd, session, state, compactSupported = false) {
29
29
  if (!contact.userId)
30
30
  return;
31
31
  const recipient = contact.chatId ?? contact.userId;
@@ -64,17 +64,21 @@ async function sendAutoResumeCard(connector, contact, cwd, session, state) {
64
64
  const usageStr = formatUsageStats(state.usage);
65
65
  elements.push(markdownDiv(usageStr));
66
66
  }
67
- // Add new session button - use column_set+column (div+elements not supported in 2.0)
68
- const newSessionBtn = newSessionButton();
67
+ // Add action buttons - use column_set+column (div+elements not supported in 2.0)
68
+ const actionButtons = [];
69
+ // Compact 按钮:仅 codex app-server 模式(runner 有 runCompact)且未在跑。
70
+ // 启动自动恢复时不会存在该会话的 activeRun,直接渲染。
71
+ if (compactSupported) {
72
+ actionButtons.push(resumeCompactButton(session.sessionId, state.agentKind ?? 'codex'));
73
+ }
74
+ actionButtons.push(newSessionButton());
69
75
  elements.push({
70
76
  tag: 'column_set',
71
- columns: [
72
- {
73
- tag: 'column',
74
- width: 'auto',
75
- elements: [newSessionBtn],
76
- },
77
- ],
77
+ columns: actionButtons.map((btn) => ({
78
+ tag: 'column',
79
+ width: 'auto',
80
+ elements: [btn],
81
+ })),
78
82
  });
79
83
  const card = {
80
84
  schema: '2.0',
@@ -163,15 +167,17 @@ function initializeRunner(config, configDir, cliArgs) {
163
167
  workspace: ws,
164
168
  });
165
169
  });
166
- // Register CodexExecRunner (codex exec --json, approval_policy=never).
167
- // Factory reads latest config from container.current so runtime config changes
168
- // (reasoningEffort, model, etc.) take effect after bridge.setConfig() + clearRunners().
170
+ // Register Codex runner. Factory reads latest config from container.current
171
+ // so runtime config changes (serviceMode, model, sandbox, approvalPolicy,
172
+ // reasoningEffort, etc.) take effect after bridge.setConfig() + clearRunners().
173
+ // serviceMode=app-server → CodexAppServerRunner (persistent connection with
174
+ // approval + sandbox); otherwise CodexExecRunner (codex exec --json).
169
175
  const codexSessionReader = new CodexSessionReader({ codexHome: process.env.CODEX_HOME });
170
176
  agentRegistry.register('codex', (ws) => {
171
177
  const container = agentRegistry.getConfigContainer();
172
178
  const latestConfig = container?.current ?? config;
173
179
  const codexConfig = getAgentConfig(latestConfig, 'codex');
174
- return new CodexExecRunner({
180
+ return createCodexRunner({
175
181
  model: codexConfig?.model,
176
182
  modelProvider: codexConfig?.modelProvider,
177
183
  reasoningEffort: codexConfig?.reasoningEffort,
@@ -179,6 +185,13 @@ function initializeRunner(config, configDir, cliArgs) {
179
185
  pidDir: configDir,
180
186
  workspace: ws,
181
187
  sessionReader: codexSessionReader,
188
+ serviceMode: codexConfig?.serviceMode,
189
+ sandbox: codexConfig?.sandbox,
190
+ approvalPolicy: codexConfig?.approvalPolicy,
191
+ appServerBinary: codexConfig?.appServer?.binary,
192
+ appServerRequestTimeoutMs: codexConfig?.appServer?.requestTimeoutMs,
193
+ appServerIdleTtlMs: codexConfig?.appServer?.idleTtlMs,
194
+ appServerTurnIdleTimeoutMinutes: codexConfig?.appServer?.turnIdleTimeoutMinutes,
182
195
  });
183
196
  });
184
197
  // Register OpencodeExecRunner (run mode)
@@ -479,11 +492,15 @@ function setupMessageHandlers(connector, router, bridge, sessionStore, workspace
479
492
  inputValue: action.raw?.action?.input_value ??
480
493
  action.action.input_value,
481
494
  };
482
- // queue.input 和 config.save 返回 CardActionResponse toast 给点击用户即时反馈。
483
- // 必须直接返回值 -- enqueueImmediate / enqueueConfigAction 是 fire-and-forget,
484
- // 会吞掉返回值。queue.input 只做一次卡片更新 + toast,快速且不需要串行队列序列化。
485
- // config.save 内部已有 enqueueConfigAction 串行化保证,但返回值需要直接返回给飞书。
486
- if (actionValue.cmd === 'queue.input' || actionValue.cmd === 'config.save') {
495
+ // queue.input / config.save / approval.respond / approval.toggle 返回
496
+ // CardActionResponse toast 给点击用户即时反馈。必须直接返回值 --
497
+ // enqueueImmediate / enqueueConfigAction 是 fire-and-forget,会吞掉返回值。
498
+ // 审批响应尤其不能落串行队列:run 任务占用队列头直到 turn 结束,审批响应
499
+ // 排在后面会形成死锁(run 不结束不执行、run 结束 coordinator 已删响应空转)。
500
+ if (actionValue.cmd === 'queue.input' ||
501
+ actionValue.cmd === 'config.save' ||
502
+ actionValue.cmd === 'approval.respond' ||
503
+ actionValue.cmd === 'approval.toggle') {
487
504
  return router.handleCardAction(fullValue, { userId, chatId, messageId });
488
505
  }
489
506
  // order.exec → equivalent queued message (Plan A): resolve the order text
@@ -514,6 +531,8 @@ function setupMessageHandlers(connector, router, bridge, sessionStore, workspace
514
531
  chatId,
515
532
  messageId,
516
533
  messagePreview: `card action: ${actionValue.cmd}`,
534
+ // Compact 是单向操作,排队卡不允许编辑(编辑预览无意义)。
535
+ editable: actionValue.cmd !== 'codex.compact' && actionValue.cmd !== 'resume.compact',
517
536
  },
518
537
  });
519
538
  }
@@ -663,7 +682,10 @@ async function main() {
663
682
  recap: content.recap,
664
683
  displayTitle: content.displayTitle,
665
684
  agentKind: config.defaultAgent,
666
- });
685
+ },
686
+ // Compact 仅 codex app-server 模式有实现(runner.runCompact)。
687
+ config.defaultAgent === 'codex' &&
688
+ getAgentConfig(config, 'codex')?.serviceMode === 'app-server');
667
689
  }
668
690
  catch (err) {
669
691
  getLogger().warn('[startup] sendAutoResumeCard failed:', err);
@@ -2,7 +2,7 @@
2
2
  * Codex config builder — builds fields and handles field changes for Codex agent.
3
3
  */
4
4
  import type { AgentConfigCardBuilder, ConfigField } from './types.js';
5
- import type { AppConfig } from '../../config/index.js';
5
+ import { type AppConfig } from '../../config/index.js';
6
6
  export declare class CodexConfigBuilder implements AgentConfigCardBuilder {
7
7
  buildFields(displayConfig: AppConfig): ConfigField[];
8
8
  handleFieldChange(key: string, value: unknown, config: AppConfig): Array<{
@@ -2,6 +2,7 @@
2
2
  * Codex config builder — builds fields and handles field changes for Codex agent.
3
3
  */
4
4
  import { loadCodexConfig, getReasoningEffortOptions, getDefaultReasoningEffort, } from '../../config/codex-config.js';
5
+ import { DEFAULT_TURN_IDLE_TIMEOUT_MINUTES } from '../../config/index.js';
5
6
  export class CodexConfigBuilder {
6
7
  buildFields(displayConfig) {
7
8
  const fields = [];
@@ -43,6 +44,54 @@ export class CodexConfigBuilder {
43
44
  options: getReasoningEffortOptions(currentCodexModel),
44
45
  currentValue: currentReasoningEffort,
45
46
  });
47
+ // Service mode (exec vs app-server)
48
+ const currentServiceMode = displayConfig.agents?.codex?.serviceMode ?? 'exec';
49
+ fields.push({
50
+ key: 'agents.codex.serviceMode',
51
+ label: '运行模式',
52
+ type: 'select',
53
+ options: ['exec', 'app-server'],
54
+ currentValue: currentServiceMode,
55
+ });
56
+ // 审批策略与沙箱模式仅 app-server 模式可配置(CodexAppServerRunner 读取);
57
+ // exec 模式在 runner 层固定 `--sandbox danger-full-access` +
58
+ // `approval_policy="never"`(src/runner/codex/argv.ts),卡片不暴露选项,
59
+ // 只用说明行把默认语义呈现给用户。
60
+ if (currentServiceMode === 'app-server') {
61
+ // Approval policy (Codex 官方 AskForApproval 标准值;on-request 为 codex 默认)
62
+ const currentApprovalPolicy = displayConfig.agents?.codex?.approvalPolicy ?? 'on-request';
63
+ fields.push({
64
+ key: 'agents.codex.approvalPolicy',
65
+ label: '审批策略',
66
+ type: 'select',
67
+ options: ['untrusted', 'on-request', 'never'],
68
+ currentValue: currentApprovalPolicy,
69
+ });
70
+ // Sandbox mode (Codex 官方 SandboxMode 标准值)
71
+ const currentSandbox = displayConfig.agents?.codex?.sandbox ?? 'danger-full-access';
72
+ fields.push({
73
+ key: 'agents.codex.sandbox',
74
+ label: '沙箱模式',
75
+ type: 'select',
76
+ options: ['read-only', 'workspace-write', 'danger-full-access'],
77
+ currentValue: currentSandbox,
78
+ });
79
+ const currentTurnIdleTimeoutMinutes = displayConfig.agents?.codex?.appServer?.turnIdleTimeoutMinutes ??
80
+ DEFAULT_TURN_IDLE_TIMEOUT_MINUTES;
81
+ fields.push({
82
+ key: 'agents.codex.appServer.turnIdleTimeoutMinutes',
83
+ label: 'Turn 空闲超时(分钟, 0关闭)',
84
+ type: 'input',
85
+ currentValue: String(currentTurnIdleTimeoutMinutes),
86
+ });
87
+ }
88
+ else {
89
+ fields.push({
90
+ key: 'agents.codex.modeNote',
91
+ label: '命令行模式固定为完全访问(danger-full-access)且无需审批(approval never),不提供审批策略/沙箱配置',
92
+ type: 'note',
93
+ });
94
+ }
46
95
  return fields;
47
96
  }
48
97
  handleFieldChange(key, value, config) {
@@ -19,6 +19,10 @@ function buildFieldElements(fields, displayConfig) {
19
19
  else if (field.type === 'select' && field.options) {
20
20
  elements.push(buildSelectElement(field, currentValue));
21
21
  }
22
+ else if (field.type === 'note') {
23
+ // 纯说明行:无 config.* 回调,只渲染 label 为 markdown 文本。
24
+ elements.push({ tag: 'div', text: { tag: 'lark_md', content: field.label } });
25
+ }
22
26
  else {
23
27
  elements.push(buildInputElement(field, currentValue));
24
28
  }
@@ -9,7 +9,7 @@ import type { AppConfig } from '../../config/index.js';
9
9
  export interface ConfigField {
10
10
  key: string;
11
11
  label: string;
12
- type: 'boolean' | 'select' | 'input';
12
+ type: 'boolean' | 'select' | 'input' | 'note';
13
13
  /** For select type: the list of options shown in the dropdown. */
14
14
  options?: readonly string[];
15
15
  /** Override the currentValue shown in the card (takes priority over getConfigValue). */