@linkshell/host 0.4.6 → 0.4.8

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 (77) hide show
  1. package/dist/host/src/computer-preview-capture.d.ts +11 -0
  2. package/dist/host/src/computer-preview-capture.js +87 -0
  3. package/dist/host/src/computer-preview-capture.js.map +1 -0
  4. package/dist/host/src/computer-preview.d.ts +73 -0
  5. package/dist/host/src/computer-preview.js +335 -0
  6. package/dist/host/src/computer-preview.js.map +1 -0
  7. package/dist/host/src/drivers/acp/connection.d.ts +2 -0
  8. package/dist/host/src/drivers/acp/connection.js +14 -1
  9. package/dist/host/src/drivers/acp/connection.js.map +1 -1
  10. package/dist/host/src/drivers/acp/driver.d.ts +1 -0
  11. package/dist/host/src/drivers/acp/driver.js +8 -0
  12. package/dist/host/src/drivers/acp/driver.js.map +1 -1
  13. package/dist/host/src/drivers/acp/mapper.js +11 -1
  14. package/dist/host/src/drivers/acp/mapper.js.map +1 -1
  15. package/dist/host/src/drivers/claude/activity.d.ts +1 -0
  16. package/dist/host/src/drivers/claude/activity.js +1 -0
  17. package/dist/host/src/drivers/claude/activity.js.map +1 -1
  18. package/dist/host/src/drivers/claude/driver.d.ts +6 -0
  19. package/dist/host/src/drivers/claude/driver.js +30 -2
  20. package/dist/host/src/drivers/claude/driver.js.map +1 -1
  21. package/dist/host/src/drivers/claude/tasks.d.ts +33 -0
  22. package/dist/host/src/drivers/claude/tasks.js +138 -0
  23. package/dist/host/src/drivers/claude/tasks.js.map +1 -0
  24. package/dist/host/src/drivers/claude/transcript.d.ts +31 -3
  25. package/dist/host/src/drivers/claude/transcript.js +20 -1
  26. package/dist/host/src/drivers/claude/transcript.js.map +1 -1
  27. package/dist/host/src/drivers/codex/app-server.js +0 -3
  28. package/dist/host/src/drivers/codex/app-server.js.map +1 -1
  29. package/dist/host/src/drivers/codex/commands.js +7 -1
  30. package/dist/host/src/drivers/codex/commands.js.map +1 -1
  31. package/dist/host/src/drivers/codex/computer-preview.d.ts +3 -0
  32. package/dist/host/src/drivers/codex/computer-preview.js +44 -0
  33. package/dist/host/src/drivers/codex/computer-preview.js.map +1 -0
  34. package/dist/host/src/drivers/codex/driver.d.ts +16 -1
  35. package/dist/host/src/drivers/codex/driver.js +286 -13
  36. package/dist/host/src/drivers/codex/driver.js.map +1 -1
  37. package/dist/host/src/drivers/codex/mapper.d.ts +3 -1
  38. package/dist/host/src/drivers/codex/mapper.js +77 -7
  39. package/dist/host/src/drivers/codex/mapper.js.map +1 -1
  40. package/dist/host/src/drivers/types.d.ts +23 -1
  41. package/dist/host/src/hub.d.ts +18 -1
  42. package/dist/host/src/hub.js +99 -1
  43. package/dist/host/src/hub.js.map +1 -1
  44. package/dist/host/src/input.d.ts +9 -0
  45. package/dist/host/src/input.js +15 -0
  46. package/dist/host/src/input.js.map +1 -1
  47. package/dist/host/src/rpc/server.d.ts +1 -0
  48. package/dist/host/src/rpc/server.js +17 -0
  49. package/dist/host/src/rpc/server.js.map +1 -1
  50. package/dist/host/src/screen-viewer.js +641 -109
  51. package/dist/host/src/screen-viewer.js.map +1 -1
  52. package/dist/host/src/store.d.ts +2 -0
  53. package/dist/host/src/store.js +4 -0
  54. package/dist/host/src/store.js.map +1 -1
  55. package/dist/host/src/task-output.d.ts +6 -0
  56. package/dist/host/src/task-output.js +22 -0
  57. package/dist/host/src/task-output.js.map +1 -0
  58. package/dist/host/tsconfig.tsbuildinfo +1 -1
  59. package/dist/wire/src/computer-preview.d.ts +35 -0
  60. package/dist/wire/src/computer-preview.js +51 -0
  61. package/dist/wire/src/computer-preview.js.map +1 -0
  62. package/dist/wire/src/goal.d.ts +66 -0
  63. package/dist/wire/src/goal.js +19 -0
  64. package/dist/wire/src/goal.js.map +1 -0
  65. package/dist/wire/src/index.d.ts +2 -0
  66. package/dist/wire/src/index.js +2 -0
  67. package/dist/wire/src/index.js.map +1 -1
  68. package/dist/wire/src/model.d.ts +78 -0
  69. package/dist/wire/src/model.js +31 -0
  70. package/dist/wire/src/model.js.map +1 -1
  71. package/dist/wire/src/rpc.d.ts +1189 -46
  72. package/dist/wire/src/rpc.js +41 -1
  73. package/dist/wire/src/rpc.js.map +1 -1
  74. package/dist/wire/src/updates.d.ts +334 -22
  75. package/dist/wire/src/updates.js +11 -1
  76. package/dist/wire/src/updates.js.map +1 -1
  77. package/package.json +4 -3
@@ -1,8 +1,9 @@
1
1
  import { statSync } from "node:fs";
2
- import { ABANDON, RpcError } from "@linkshell/wire";
2
+ import { codexPreview } from "./computer-preview.js";
3
+ import { ABANDON, RpcError, sessionGoalSchema } from "@linkshell/wire";
3
4
  import { parseCodexLoginStatus, runStatusCommand } from "../auth.js";
4
5
  import { CodexAppServer, CodexSharedServer, detectCodex, sharedServerSocket } from "./app-server.js";
5
- import { APPROVAL_METHODS, QUESTION_METHODS, mapApprovalRequest, mapQuestionRequest, mapNotification, threadStateOf, threadToDiscovered, spawnedThreads, threadToHistory, toCodexInput, toolStart, turnUnderWay, } from "./mapper.js";
6
+ import { APPROVAL_METHODS, QUESTION_METHODS, mapApprovalRequest, mapQuestionRequest, mapNotification, threadStateOf, threadToDiscovered, spawnedThreads, subagentHistory, threadToHistory, toCodexInput, toolStart, turnUnderWay, } from "./mapper.js";
6
7
  import { nestHistory, nestUnder } from "../nesting.js";
7
8
  import { configOptions, effective, settingsFrom, turnOverrides } from "./settings.js";
8
9
  import { COMMANDS, INIT_PROMPT, commandOf } from "./commands.js";
@@ -79,6 +80,9 @@ export class CodexDriver {
79
80
  /** Each thread's working directory: where its skills are looked up. */
80
81
  cwds = new Map();
81
82
  skills = new Map();
83
+ goalAvailable = new Set();
84
+ goalRevision = new Map();
85
+ skillsRevision = 0;
82
86
  models;
83
87
  restartTimer;
84
88
  restartDelay;
@@ -93,6 +97,11 @@ export class CodexDriver {
93
97
  /** Notifications for a thread being joined, held until its history has been read. */
94
98
  joining = new Map();
95
99
  /** Threads a device has open. */
100
+ taskProcesses = new Map();
101
+ taskPolls = new Map();
102
+ taskSyncs = new Map();
103
+ stoppingTasks = new Set();
104
+ taskRevisions = new Map();
96
105
  watching = new Set();
97
106
  /** Threads another client of this host's app-server opened (`linkshell codex`): followed for as long as the host runs. */
98
107
  kept = new Set();
@@ -134,6 +143,9 @@ export class CodexDriver {
134
143
  return parseCodexLoginStatus(await runStatusCommand(this.options.command ?? "codex", ["login", "status"], this.options.env));
135
144
  }
136
145
  async stop() {
146
+ for (const timer of this.taskPolls.values())
147
+ clearTimeout(timer);
148
+ this.taskPolls.clear();
137
149
  this.stopped = true;
138
150
  if (this.restartTimer)
139
151
  clearTimeout(this.restartTimer);
@@ -226,6 +238,7 @@ export class CodexDriver {
226
238
  // rollout yet, so thread/resume would fail. Nothing to import either.
227
239
  void this.announceConfig(nativeId);
228
240
  void this.announceCommands(nativeId);
241
+ void this.announceGoal(nativeId);
229
242
  return [];
230
243
  }
231
244
  let joined;
@@ -250,6 +263,7 @@ export class CodexDriver {
250
263
  this.reportHeld(nativeId, false);
251
264
  void this.announceConfig(nativeId);
252
265
  void this.announceCommands(nativeId);
266
+ void this.announceGoal(nativeId);
253
267
  if (this.waiting.has(nativeId))
254
268
  void this.syncWaiting(nativeId);
255
269
  this.considerRelease(nativeId);
@@ -317,6 +331,7 @@ export class CodexDriver {
317
331
  }
318
332
  /** Says where the joined thread stands, then lets through what arrived while it was being read. */
319
333
  goLive(threadId, { thread, underWay }) {
334
+ void this.syncTasks(threadId);
320
335
  const state = this.stateOf(threadId);
321
336
  const active = thread.turns?.find((turn) => turn.status === "inProgress");
322
337
  state.activeTurnId = active?.id;
@@ -370,6 +385,8 @@ export class CodexDriver {
370
385
  this.cwds.set(threadId, thread.cwd);
371
386
  this.host?.update(this.id, threadId, { sessionUpdate: "ls_status", state: running ? "running" : "idle" });
372
387
  this.reportHeld(threadId, true);
388
+ void this.announceCommands(threadId);
389
+ void this.announceGoal(threadId);
373
390
  // What was queued for it before this host last started is still waiting.
374
391
  void this.syncWaiting(threadId);
375
392
  this.considerRelease(threadId);
@@ -380,10 +397,19 @@ export class CodexDriver {
380
397
  const watch = this.observed.get(threadId);
381
398
  if (!watch || watch.reading)
382
399
  return;
400
+ if (!watch.goalReading && this.goalAvailable.has(threadId) && Date.now() - (watch.goalCheckedAt ?? 0) >= 5000) {
401
+ watch.goalCheckedAt = Date.now();
402
+ watch.goalReading = true;
403
+ // Goal changes live in SQLite and need not change the rollout file.
404
+ void this.goal(threadId, { action: "get" }).catch(() => { }).finally(() => { watch.goalReading = false; });
405
+ }
383
406
  if (this.waiting.has(threadId))
384
407
  void this.syncWaiting(threadId);
385
408
  const stamp = watch.path ? fileStamp(watch.path) : undefined;
386
- if (stamp !== undefined && stamp === watch.stamp)
409
+ // A parent waiting for its agents may not write anything while their own
410
+ // rollout files keep growing. Each of those files can invalidate the read.
411
+ const childrenChanged = [...this.children.values()].some((child) => child.threadId === threadId && (!child.path || child.stamp === undefined || fileStamp(child.path) !== child.stamp));
412
+ if (stamp !== undefined && stamp === watch.stamp && !childrenChanged)
387
413
  return;
388
414
  watch.reading = true;
389
415
  try {
@@ -462,6 +488,7 @@ export class CodexDriver {
462
488
  this.reportHeld(threadId, false);
463
489
  void this.announceConfig(threadId);
464
490
  void this.announceCommands(threadId);
491
+ void this.announceGoal(threadId);
465
492
  // What still waits in Codex's queue is started by this host's app-server now.
466
493
  void this.syncWaiting(threadId);
467
494
  })().finally(() => this.takingUp.delete(threadId));
@@ -640,6 +667,7 @@ export class CodexDriver {
640
667
  watched(nativeId, open) {
641
668
  if (open) {
642
669
  this.watching.add(nativeId);
670
+ void this.syncTasks(nativeId);
643
671
  this.cancelRelease(nativeId);
644
672
  }
645
673
  else {
@@ -647,6 +675,84 @@ export class CodexDriver {
647
675
  this.considerRelease(nativeId);
648
676
  }
649
677
  }
678
+ publishTask(threadId, task) {
679
+ const { lastSeq: _, ...record } = task;
680
+ this.host?.update(this.id, threadId, { sessionUpdate: "ls_task", task: record });
681
+ }
682
+ loseTasks(threadId) {
683
+ clearTimeout(this.taskPolls.get(threadId));
684
+ this.taskPolls.delete(threadId);
685
+ this.taskProcesses.delete(threadId);
686
+ this.taskRevisions.set(threadId, (this.taskRevisions.get(threadId) ?? 0) + 1);
687
+ for (const task of this.host?.tasks(this.id, threadId) ?? []) {
688
+ if (task.state === "running")
689
+ this.publishTask(threadId, { ...task, state: "unknown", endedAt: Date.now(), canStop: false });
690
+ }
691
+ }
692
+ syncTasks(threadId) {
693
+ const pending = this.taskSyncs.get(threadId);
694
+ if (pending)
695
+ return pending;
696
+ if (!this.attached.has(threadId) || this.observed.has(threadId))
697
+ return Promise.resolve();
698
+ const sync = this.readTasks(threadId).catch(() => { }).finally(() => {
699
+ this.taskSyncs.delete(threadId);
700
+ clearTimeout(this.taskPolls.get(threadId));
701
+ this.taskPolls.delete(threadId);
702
+ if (this.attached.has(threadId) && this.watching.has(threadId) && this.host?.tasks(this.id, threadId).some((task) => task.state === "running")) {
703
+ const timer = setTimeout(() => void this.syncTasks(threadId), 5000);
704
+ timer.unref?.();
705
+ this.taskPolls.set(threadId, timer);
706
+ }
707
+ });
708
+ this.taskSyncs.set(threadId, sync);
709
+ return sync;
710
+ }
711
+ async readTasks(threadId) {
712
+ const revision = this.taskRevisions.get(threadId) ?? 0;
713
+ const entries = [];
714
+ let cursor;
715
+ do {
716
+ const page = await this.rpcFor(threadId, "thread/backgroundTerminals/list", { threadId, limit: 100, ...(cursor ? { cursor } : {}) });
717
+ entries.push(...page.data);
718
+ cursor = page.nextCursor ?? undefined;
719
+ } while (cursor);
720
+ if (!this.attached.has(threadId) || revision !== (this.taskRevisions.get(threadId) ?? 0))
721
+ return;
722
+ const previous = new Map((this.host?.tasks(this.id, threadId) ?? []).map((task) => [task.id, task]));
723
+ const processes = new Map();
724
+ for (const entry of entries) {
725
+ processes.set(entry.itemId, entry.processId);
726
+ const old = previous.get(entry.itemId);
727
+ if (old?.state === "running")
728
+ continue;
729
+ this.publishTask(threadId, { id: entry.itemId, kind: "shell", toolCallId: entry.itemId, title: this.toolTitles.get(entry.itemId) ?? entry.command, command: entry.command, state: "running", startedAt: old?.startedAt ?? Date.now(), output: true, canStop: true });
730
+ }
731
+ this.taskProcesses.set(threadId, processes);
732
+ for (const task of previous.values()) {
733
+ if (task.state === "running" && !processes.has(task.id))
734
+ this.publishTask(threadId, { ...task, state: "unknown", endedAt: Date.now(), canStop: false });
735
+ }
736
+ }
737
+ async stopTask(nativeId, taskId) {
738
+ await this.syncTasks(nativeId);
739
+ const processId = this.taskProcesses.get(nativeId)?.get(taskId);
740
+ if (!processId)
741
+ throw RpcError.app("not_found", "这个后台任务已经不在运行列表里");
742
+ const key = `${nativeId}:${taskId}`;
743
+ this.stoppingTasks.add(key);
744
+ try {
745
+ const result = await this.rpcFor(nativeId, "thread/backgroundTerminals/terminate", { threadId: nativeId, processId });
746
+ if (!result.terminated)
747
+ throw RpcError.app("busy", "没能停止这个后台任务");
748
+ const task = this.host?.tasks(this.id, nativeId).find((task) => task.id === taskId);
749
+ if (task?.state === "running")
750
+ this.publishTask(nativeId, { ...task, state: "stopped", endedAt: Date.now(), canStop: false });
751
+ }
752
+ finally {
753
+ this.stoppingTasks.delete(key);
754
+ }
755
+ }
650
756
  cancelRelease(threadId) {
651
757
  clearTimeout(this.releases.get(threadId));
652
758
  this.releases.delete(threadId);
@@ -707,6 +813,17 @@ export class CodexDriver {
707
813
  }
708
814
  /** Nests each sub-agent's own history right after the call that spawned it. */
709
815
  async withSubAgents(threadId, thread, history) {
816
+ // Restore the last surface across idle turns and history windows.
817
+ outer: for (const turn of [...(thread.turns ?? [])].reverse()) {
818
+ for (const item of [...turn.items].reverse()) {
819
+ const stamp = turn.completedAt ?? turn.startedAt ?? thread.updatedAt;
820
+ const frame = codexPreview(item, stamp < 1e12 ? stamp * 1000 : stamp);
821
+ if (frame) {
822
+ this.host?.preview?.(this.id, threadId, frame);
823
+ break outer;
824
+ }
825
+ }
826
+ }
710
827
  const spawns = new Map();
711
828
  for (const turn of thread.turns ?? []) {
712
829
  for (const item of turn.items) {
@@ -715,17 +832,31 @@ export class CodexDriver {
715
832
  if (!callId || ids.length === 0)
716
833
  continue;
717
834
  spawns.set(callId, ids);
718
- for (const child of ids)
719
- this.children.set(child, { threadId, toolCallId: callId });
835
+ for (const child of ids) {
836
+ this.subThreads.add(child);
837
+ this.children.set(child, { ...this.children.get(child), threadId, toolCallId: callId });
838
+ }
720
839
  }
721
840
  }
722
841
  if (spawns.size === 0)
723
842
  return history;
724
843
  const nested = new Map();
725
844
  await Promise.all([...spawns].map(async ([callId, ids]) => {
726
- const parts = await Promise.all(ids.map((child) => this.rpcFor(threadId, "thread/read", { threadId: child, includeTurns: true })
727
- .then((result) => nestHistory(threadToHistory(result.thread), callId))
728
- .catch(() => [])));
845
+ const parts = await Promise.all(ids.map(async (child) => {
846
+ const known = this.children.get(child);
847
+ const stamp = known.path ? fileStamp(known.path) : undefined;
848
+ try {
849
+ const result = await this.rpcFor(threadId, "thread/read", { threadId: child, includeTurns: true });
850
+ known.path = result.thread.path ?? undefined;
851
+ known.stamp = stamp;
852
+ return nestHistory(subagentHistory(result.thread), callId);
853
+ }
854
+ catch {
855
+ // A just-started child may not have written its rollout yet.
856
+ known.stamp = undefined;
857
+ return [];
858
+ }
859
+ }));
729
860
  nested.set(callId, parts.flat());
730
861
  }));
731
862
  return history.flatMap((item) => {
@@ -735,6 +866,9 @@ export class CodexDriver {
735
866
  });
736
867
  }
737
868
  async detach(nativeId) {
869
+ clearTimeout(this.taskPolls.get(nativeId));
870
+ this.taskPolls.delete(nativeId);
871
+ this.taskProcesses.delete(nativeId);
738
872
  this.cancelRelease(nativeId);
739
873
  this.stopObserving(nativeId);
740
874
  if (!this.attached.delete(nativeId))
@@ -757,6 +891,61 @@ export class CodexDriver {
757
891
  */
758
892
  async runCommand(nativeId, command, clientMessageId) {
759
893
  const echo = () => this.host?.update(this.id, nativeId, { sessionUpdate: "user_message_chunk", messageId: `local-${clientMessageId}`, content: { type: "text", text: command.text } }, `command:${clientMessageId}`);
894
+ if (command.name === "goal") {
895
+ const args = command.args;
896
+ const change = !args ? { action: "get" }
897
+ : args === "pause" || args === "resume" || args === "clear" ? { action: args }
898
+ : { action: "set", objective: args.replace(/^edit\s+/, "") };
899
+ if (args === "edit")
900
+ throw RpcError.app("invalid_params", "请在 /goal edit 后写上新的目标,或打开目标面板编辑");
901
+ const goal = await this.goal(nativeId, change);
902
+ echo();
903
+ this.host?.update(this.id, nativeId, { sessionUpdate: "ls_notice", level: "info", title: goal ? `目标:${goal.objective}` : "当前没有目标" });
904
+ return "started";
905
+ }
906
+ if (command.name === "reload-skills") {
907
+ this.skillsRevision++;
908
+ this.skills.delete(this.cwds.get(nativeId) ?? "");
909
+ await this.loadSkills(this.cwds.get(nativeId), true);
910
+ await this.announceCommands(nativeId);
911
+ echo();
912
+ this.host?.update(this.id, nativeId, { sessionUpdate: "ls_notice", level: "info", title: "已刷新可用技能" });
913
+ return "started";
914
+ }
915
+ if (["status", "mcp", "apps", "ps", "stop"].includes(command.name)) {
916
+ if (command.args)
917
+ throw RpcError.app("invalid_params", `/${command.name} 暂不接受参数`);
918
+ const lines = [];
919
+ if (command.name === "status") {
920
+ const { thread } = await this.rpcFor(nativeId, "thread/read", { threadId: nativeId, includeTurns: false });
921
+ const current = effective(this.settings.get(nativeId) ?? {}, this.overrides.get(nativeId) ?? {}, await this.loadModels());
922
+ const running = this.observed.get(nativeId)?.running ?? Boolean(this.stateOf(nativeId).activeTurnId);
923
+ lines.push(`目录:${thread.cwd}`, `模型:${current.model ?? "默认"}`, `推理强度:${current.effort ?? "默认"}`, `状态:${running ? "运行中" : "空闲"}`);
924
+ }
925
+ else if (command.name === "stop") {
926
+ await this.rpcFor(nativeId, "thread/backgroundTerminals/clean", { threadId: nativeId });
927
+ lines.push("已停止当前会话的后台终端");
928
+ }
929
+ else {
930
+ const method = command.name === "mcp" ? "mcpServerStatus/list" : command.name === "apps" ? "app/list" : "thread/backgroundTerminals/list";
931
+ let cursor;
932
+ const seen = new Set();
933
+ do {
934
+ const page = await this.rpcFor(nativeId, method, { threadId: nativeId, limit: 100, ...(cursor ? { cursor } : {}) });
935
+ for (const entry of page.data) {
936
+ lines.push(command.name === "ps" ? `${entry.processId ?? ""} · ${entry.command ?? "后台终端"}` : command.name === "mcp" ? `${entry.name ?? "MCP"} · ${Object.keys(entry.tools ?? {}).length} 个工具 · ${entry.authStatus ?? ""}` : `${entry.name ?? "应用"} · ${entry.isEnabled ? "已启用" : "未启用"}`);
937
+ }
938
+ cursor = page.nextCursor ?? undefined;
939
+ if (cursor && seen.has(cursor))
940
+ throw RpcError.app("invalid_params", "Agent 返回了重复的分页游标,请重试");
941
+ if (cursor)
942
+ seen.add(cursor);
943
+ } while (cursor);
944
+ }
945
+ echo();
946
+ this.host?.update(this.id, nativeId, { sessionUpdate: "ls_notice", level: "info", title: `/${command.name}`, detail: lines.join("\n") || "当前没有记录" });
947
+ return "started";
948
+ }
760
949
  if (command.name === "compact" || command.name === "review") {
761
950
  // Not something a queue can carry: they run in the app-server that has the thread.
762
951
  if (this.observed.has(nativeId))
@@ -783,7 +972,7 @@ export class CodexDriver {
783
972
  }
784
973
  const skill = (await this.loadSkills(this.cwds.get(nativeId))).find((entry) => entry.name === command.name);
785
974
  if (!skill)
786
- return undefined;
975
+ throw RpcError.app("not_supported", `当前 Codex 未提供 /${command.name}。请从命令面板选择可用命令。`);
787
976
  // The way the TUI sends a skill: the skill itself, and `$name` in the text.
788
977
  return this.send(nativeId, [
789
978
  { type: "skill", name: skill.name, path: skill.path },
@@ -820,6 +1009,41 @@ export class CodexDriver {
820
1009
  });
821
1010
  return "started";
822
1011
  }
1012
+ async goal(nativeId, change) {
1013
+ if (this.observed.has(nativeId) && change.action !== "get")
1014
+ throw this.refused(nativeId, "会话开在电脑的另一个 Codex 里 · 暂时只能查看目标", HELD_COMMAND);
1015
+ const method = change.action === "get" ? "thread/goal/get" : change.action === "clear" ? "thread/goal/clear" : "thread/goal/set";
1016
+ let status = "active";
1017
+ if (change.action === "set") {
1018
+ const current = await this.rpcFor(nativeId, "thread/goal/get", { threadId: nativeId });
1019
+ // Editing a paused goal must not silently restart work. This matches the
1020
+ // native editor; finished or exhausted goals start afresh when edited.
1021
+ if (current.goal && ["paused", "blocked", "usageLimited"].includes(current.goal.status))
1022
+ status = current.goal.status;
1023
+ }
1024
+ const params = change.action === "set"
1025
+ ? { threadId: nativeId, objective: change.objective, status, ...(change.tokenBudget === undefined ? {} : { tokenBudget: change.tokenBudget }) }
1026
+ : { threadId: nativeId, ...(change.action === "pause" ? { status: "paused" } : change.action === "resume" ? { status: "active" } : {}) };
1027
+ const revision = this.goalRevision.get(nativeId) ?? 0;
1028
+ const result = await this.rpcFor(nativeId, method, params);
1029
+ const goal = change.action === "clear" || result.goal == null ? null : sessionGoalSchema.parse(result.goal);
1030
+ this.goalAvailable.add(nativeId);
1031
+ // A live update can overtake a snapshot request. Never overwrite it with
1032
+ // the older response (especially a null read from just before /goal set).
1033
+ if ((this.goalRevision.get(nativeId) ?? 0) === revision && this.isNews(`goal:${nativeId}`, goal))
1034
+ this.host?.update(this.id, nativeId, { sessionUpdate: "ls_goal", goal });
1035
+ return goal;
1036
+ }
1037
+ async announceGoal(nativeId) {
1038
+ try {
1039
+ await this.goal(nativeId, { action: "get" });
1040
+ await this.announceCommands(nativeId);
1041
+ }
1042
+ catch (error) {
1043
+ // Older Codex builds do not implement Goals; ordinary sessions still work.
1044
+ this.host?.log(`[codex] goal unavailable: ${error instanceof Error ? error.message : String(error)}`);
1045
+ }
1046
+ }
823
1047
  /** Model, reasoning effort and permissions apply from the next turn on. */
824
1048
  async setConfig(nativeId, optionId, value) {
825
1049
  const models = await this.loadModels();
@@ -880,15 +1104,18 @@ export class CodexDriver {
880
1104
  this.announced.set(key, text);
881
1105
  return true;
882
1106
  }
883
- loadSkills(cwd) {
1107
+ loadSkills(cwd, forceReload = false) {
884
1108
  const key = cwd ?? "";
885
1109
  let loading = this.skills.get(key);
886
1110
  if (!loading) {
887
- loading = this.rpc("skills/list", cwd ? { cwds: [cwd] } : {})
1111
+ loading = this.rpc("skills/list", { ...(cwd ? { cwds: [cwd] } : {}), ...(forceReload ? { forceReload } : {}) })
888
1112
  .then((result) => result.data.flatMap((entry) => entry.skills ?? []).filter((skill) => skill.enabled !== false && skill.name && skill.path))
889
1113
  .catch((error) => {
890
- this.skills.delete(key);
1114
+ if (this.skills.get(key) === loading)
1115
+ this.skills.delete(key);
891
1116
  this.host?.log(`[codex] skills/list failed: ${error instanceof Error ? error.message : String(error)}`);
1117
+ if (forceReload)
1118
+ throw error;
892
1119
  return [];
893
1120
  });
894
1121
  this.skills.set(key, loading);
@@ -897,10 +1124,19 @@ export class CodexDriver {
897
1124
  }
898
1125
  /** What `/` offers on a device: the commands above, then the skills this project can use. */
899
1126
  async announceCommands(nativeId) {
1127
+ // Built-ins must be usable even while skill discovery is slow or unavailable.
1128
+ if (!this.announced.has(`commands:${nativeId}`)) {
1129
+ this.isNews(`commands:${nativeId}`, COMMANDS);
1130
+ this.host?.update(this.id, nativeId, { sessionUpdate: "available_commands_update", availableCommands: COMMANDS });
1131
+ }
1132
+ const revision = this.skillsRevision;
900
1133
  const skills = await this.loadSkills(this.cwds.get(nativeId));
1134
+ if (revision !== this.skillsRevision)
1135
+ return;
901
1136
  const taken = new Set(COMMANDS.map((command) => command.name));
902
1137
  const availableCommands = [
903
1138
  ...COMMANDS,
1139
+ ...(this.goalAvailable.has(nativeId) ? [{ name: "goal", description: "设置、查看、暂停、继续或清除持续目标", hint: "目标 / pause / resume / clear" }] : []),
904
1140
  ...skills
905
1141
  .filter((skill) => !taken.has(skill.name))
906
1142
  .map((skill) => ({
@@ -1044,6 +1280,7 @@ export class CodexDriver {
1044
1280
  // (What is joined in Codex's background server is still there.)
1045
1281
  if (this.inShared.has(threadId))
1046
1282
  continue;
1283
+ this.loseTasks(threadId);
1047
1284
  this.attached.delete(threadId);
1048
1285
  this.threads.delete(threadId);
1049
1286
  this.host?.update(this.id, threadId, { sessionUpdate: "ls_status", state: "offline" });
@@ -1082,6 +1319,23 @@ export class CodexDriver {
1082
1319
  return state;
1083
1320
  }
1084
1321
  onNotification(method, params, from) {
1322
+ if (method === "skills/changed") {
1323
+ this.skillsRevision++;
1324
+ this.skills.clear();
1325
+ for (const id of this.attached)
1326
+ void this.announceCommands(id);
1327
+ return;
1328
+ }
1329
+ if (method === "thread/goal/updated" || method === "thread/goal/cleared") {
1330
+ const id = params?.threadId;
1331
+ if (id) {
1332
+ this.goalRevision.set(id, (this.goalRevision.get(id) ?? 0) + 1);
1333
+ if (this.attached.has(id) && !this.goalAvailable.has(id)) {
1334
+ this.goalAvailable.add(id);
1335
+ void this.announceCommands(id);
1336
+ }
1337
+ }
1338
+ }
1085
1339
  if (method === "thread/deleted") {
1086
1340
  // Deleted in the TUI or another client.
1087
1341
  const threadId = params?.threadId;
@@ -1122,10 +1376,26 @@ export class CodexDriver {
1122
1376
  return;
1123
1377
  }
1124
1378
  this.noteSpawns(params);
1379
+ if (method === "item/completed" && typeof threadId === "string") {
1380
+ const item = params.item;
1381
+ const task = this.host?.tasks(this.id, threadId).find((task) => task.id === item?.id);
1382
+ if (task && task.state === "running") {
1383
+ this.taskRevisions.set(threadId, (this.taskRevisions.get(threadId) ?? 0) + 1);
1384
+ const stopped = this.stoppingTasks.delete(`${threadId}:${task.id}`);
1385
+ this.publishTask(threadId, { ...task, state: stopped ? "stopped" : item?.exitCode === 0 ? "completed" : item?.exitCode != null || item?.status === "failed" ? "failed" : "unknown", endedAt: Date.now(), exitCode: item?.exitCode ?? undefined, canStop: false });
1386
+ }
1387
+ }
1388
+ if (method === "item/completed" && typeof threadId === "string") {
1389
+ const frame = codexPreview(params.item);
1390
+ if (frame)
1391
+ this.host?.preview?.(this.id, threadId, frame);
1392
+ }
1125
1393
  for (const mapped of mapNotification(method, params, (id) => this.stateOf(id))) {
1126
1394
  const update = from === "shared" && mapped.update.sessionUpdate === "ls_permission_resolved"
1127
1395
  ? { ...mapped.update, requestId: sharedRequestId(mapped.update.requestId) }
1128
1396
  : mapped.update;
1397
+ if (update.sessionUpdate === "ls_goal" && !this.isNews(`goal:${mapped.threadId}`, update.goal))
1398
+ continue;
1129
1399
  if (update.sessionUpdate === "tool_call")
1130
1400
  this.rememberTitle(update.toolCallId, update.title);
1131
1401
  if (update.sessionUpdate === "ls_permission_resolved") {
@@ -1154,8 +1424,10 @@ export class CodexDriver {
1154
1424
  continue;
1155
1425
  if (update.state === "started")
1156
1426
  this.fresh.delete(mapped.threadId);
1157
- else
1427
+ else {
1428
+ void this.syncTasks(mapped.threadId);
1158
1429
  this.considerRelease(mapped.threadId);
1430
+ }
1159
1431
  }
1160
1432
  }
1161
1433
  /** Remembers which spawnAgent call each new sub-agent thread belongs to. */
@@ -1230,6 +1502,7 @@ export class CodexDriver {
1230
1502
  const joined = [...this.inShared].filter((threadId) => this.attached.delete(threadId));
1231
1503
  this.inShared.clear();
1232
1504
  for (const threadId of joined) {
1505
+ this.loseTasks(threadId);
1233
1506
  const turnId = this.stateOf(threadId).activeTurnId;
1234
1507
  this.threads.delete(threadId);
1235
1508
  // Whether the turn goes on is told again when the thread is joined again.