tailii-host 0.9.0 → 0.10.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 (47) hide show
  1. package/dist/backend/herdr.js +8 -1
  2. package/dist/backend/herdr.js.map +1 -1
  3. package/dist/backend/tmux.js +90 -15
  4. package/dist/backend/tmux.js.map +1 -1
  5. package/dist/chat/subagentTranscript.js +4 -0
  6. package/dist/chat/subagentTranscript.js.map +1 -1
  7. package/dist/chat/transcriptTailer.js +8 -0
  8. package/dist/chat/transcriptTailer.js.map +1 -1
  9. package/dist/codex/codexAppServer.d.ts +74 -3
  10. package/dist/codex/codexAppServer.js +252 -12
  11. package/dist/codex/codexAppServer.js.map +1 -1
  12. package/dist/codex/codexNativeTurnController.d.ts +99 -2
  13. package/dist/codex/codexNativeTurnController.js +218 -8
  14. package/dist/codex/codexNativeTurnController.js.map +1 -1
  15. package/dist/codex/codexPlanItem.d.ts +14 -0
  16. package/dist/codex/codexPlanItem.js +50 -0
  17. package/dist/codex/codexPlanItem.js.map +1 -0
  18. package/dist/codex/codexRolloutTailer.d.ts +10 -0
  19. package/dist/codex/codexRolloutTailer.js +60 -2
  20. package/dist/codex/codexRolloutTailer.js.map +1 -1
  21. package/dist/codex/codexSystemNotice.d.ts +22 -1
  22. package/dist/codex/codexSystemNotice.js +46 -0
  23. package/dist/codex/codexSystemNotice.js.map +1 -1
  24. package/dist/engine/engine.js +1 -0
  25. package/dist/engine/engine.js.map +1 -1
  26. package/dist/engine/handlers/codex.js +36 -1
  27. package/dist/engine/handlers/codex.js.map +1 -1
  28. package/dist/hub/hubProtocol.d.ts +22 -1
  29. package/dist/hub/hubProtocol.js +53 -1
  30. package/dist/hub/hubProtocol.js.map +1 -1
  31. package/dist/hub/sessionHub.d.ts +12 -0
  32. package/dist/hub/sessionHub.js +96 -5
  33. package/dist/hub/sessionHub.js.map +1 -1
  34. package/dist/protocol/decode.js +52 -0
  35. package/dist/protocol/decode.js.map +1 -1
  36. package/dist/protocol/messages.d.ts +58 -0
  37. package/dist/sessions/claudeSessionStore.js +4 -0
  38. package/dist/sessions/claudeSessionStore.js.map +1 -1
  39. package/dist/sessions/sessionSearch.js +5 -0
  40. package/dist/sessions/sessionSearch.js.map +1 -1
  41. package/dist/shared/clientBuild.d.ts +1 -1
  42. package/dist/shared/clientBuild.js +1 -1
  43. package/dist/shared/clientBuild.js.map +1 -1
  44. package/dist/shared/compactSummary.d.ts +9 -0
  45. package/dist/shared/compactSummary.js +16 -0
  46. package/dist/shared/compactSummary.js.map +1 -0
  47. package/package.json +3 -3
@@ -1,5 +1,5 @@
1
1
  import type { CodexAppServerApprovalPolicy, CodexAppServerThreadOptions, CodexThreadTitleGenerationResult, CodexThreadTitleSource } from "./codexAppServer.js";
2
- import { type Decision, type QuestionAnswer, type QuestionPromptQuestion } from "../protocol.js";
2
+ import { type CodexCollaborationMode, type CodexGoalAction, type CodexGoalInfo, type Decision, type QuestionAnswer, type QuestionPromptQuestion } from "../protocol.js";
3
3
  import type { ControlMessage } from "../protocol.js";
4
4
  export interface CodexNativeApproval {
5
5
  id: string;
@@ -26,6 +26,14 @@ export interface CodexNativeTurnControllerOptions {
26
26
  itemId: string;
27
27
  payload: ControlMessage;
28
28
  }) => void;
29
+ /**
30
+ * 目標の現在値(codex-goal)。`thread/goal/updated` / `cleared`、goal RPC の結果、会話オープン時の
31
+ * 読み取りから配る。null は「目標なし」。Hub は会話 stream(codex_goal_state)へ流し、目標が
32
+ * active な間は購読者ゼロでも thread 購読を保持する(継続 turn は server 起点のため)。
33
+ */
34
+ onGoal?: (session: string, goal: CodexGoalInfo | null) => void;
35
+ /** thread の collaboration mode(`thread/settings/updated`)。Hub は `pc:collab` マーカーで iOS へ配る。 */
36
+ onCollaborationMode?: (session: string, mode: CodexCollaborationMode) => void;
29
37
  onDisconnect?: (session: string, error: Error) => void;
30
38
  onThreadTitle?: (event: {
31
39
  session: string;
@@ -53,8 +61,14 @@ export interface CodexTurnControllerRuntime {
53
61
  effort?: string | null;
54
62
  sandbox?: "read-only" | "workspace-write" | "danger-full-access" | null;
55
63
  approvalPolicy?: CodexAppServerApprovalPolicy | null;
64
+ /** collaboration mode(プランモード, codex-plan-mode)。未指定は thread の現状のまま。 */
65
+ collaborationMode?: CodexCollaborationMode | null;
56
66
  }): Promise<string>;
57
67
  interruptTurn?(session: string): Promise<void>;
68
+ /** 目標の読み取り / 設定 / 解除(codex-goal)。thread を開いていなければ開いてから実行する。 */
69
+ goal?(options: CodexGoalOperation): Promise<CodexGoalOperationResult>;
70
+ /** 目標が active な会話か(Hub が購読者ゼロでも thread 購読を保持する判定)。 */
71
+ hasActiveGoal?(session: string): boolean;
58
72
  /**
59
73
  * rollout の terminal event(task_complete / turn_aborted)を、現在追跡中の
60
74
  * 同一 turn に限って完了へ反映する。App Server の turn/completed 通知欠落を補う副経路。
@@ -85,11 +99,50 @@ export interface CodexThreadClient {
85
99
  /** 子 thread の実行モデル(thread/read の Thread.model)。無ければ省略。 */
86
100
  model?: string;
87
101
  } | undefined>;
88
- startTurn(text: string, clientUserMessageId?: string | null, effort?: string | null, sandbox?: "read-only" | "workspace-write" | "danger-full-access" | null, approvalPolicy?: CodexAppServerApprovalPolicy | null): Promise<string>;
102
+ startTurn(text: string, clientUserMessageId?: string | null, effort?: string | null, sandbox?: "read-only" | "workspace-write" | "danger-full-access" | null, approvalPolicy?: CodexAppServerApprovalPolicy | null, collaborationMode?: CodexCollaborationMode | null): Promise<string>;
89
103
  steerTurn(turnId: string, text: string, clientUserMessageId?: string | null): Promise<void>;
90
104
  interruptTurn(turnId: string): Promise<void>;
105
+ /** `thread/settings/updated` の取り込み(モデル / effort / collaboration mode の追従, codex-plan-mode)。 */
106
+ noteThreadSettings?(settings: Record<string, unknown>): void;
107
+ /** この接続で最後に観測 / 送信した collaboration mode(server の thread 設定に残る値)。不明は null。 */
108
+ readonly collaborationMode?: CodexCollaborationMode | null;
109
+ /** plan 直前の model / effort(default 復帰で戻す値)。plan 中でなければ null。未対応は undefined。 */
110
+ readonly planRestoreSnapshot?: CodexPlanRestoreSnapshot | null;
111
+ /** 接続を作り直したときに controller が預かっていた plan 直前の値を引き継ぐ(既にあれば上書きしない)。 */
112
+ seedPlanRestoreSnapshot?(snapshot: CodexPlanRestoreSnapshot): void;
113
+ /** `model/rerouted` 等のモデルだけの変更の取り込み。 */
114
+ noteModel?(model: string): void;
115
+ /** 目標 API(codex-goal)。旧 App Server / テスト fake では未提供。 */
116
+ goalGet?(): Promise<CodexGoalInfo | null>;
117
+ goalSet?(params: {
118
+ objective?: string;
119
+ status?: string;
120
+ tokenBudget?: number;
121
+ }): Promise<CodexGoalInfo>;
122
+ goalClear?(): Promise<boolean>;
91
123
  close(): void;
92
124
  }
125
+ /** plan に入る直前の thread 設定(default 復帰で戻す, codex-plan-mode)。 */
126
+ export interface CodexPlanRestoreSnapshot {
127
+ model: string | null;
128
+ effort: string | null;
129
+ }
130
+ /** 目標操作の入力(Hub の `codex_goal_submit` と同形)。 */
131
+ export interface CodexGoalOperation {
132
+ session: string;
133
+ threadId: string;
134
+ cwd: string;
135
+ action: CodexGoalAction;
136
+ objective?: string;
137
+ status?: string;
138
+ tokenBudget?: number;
139
+ }
140
+ export interface CodexGoalOperationResult {
141
+ /** 操作後の現在値(clear 後や未設定は null)。 */
142
+ goal: CodexGoalInfo | null;
143
+ /** clear の結果(server が何かを消したか)。 */
144
+ cleared?: boolean;
145
+ }
93
146
  export interface CodexSubscriptionSnapshot {
94
147
  itemIds: ReadonlySet<string>;
95
148
  contentCounts: ReadonlyMap<string, number>;
@@ -103,6 +156,11 @@ export interface CodexSubscriptionSnapshot {
103
156
  * 反映する。null は不明(未materialize で resume が成立しない・旧 App Server)。
104
157
  */
105
158
  model?: string | null;
159
+ /**
160
+ * 購読時点で thread に残っている collaboration mode(controller が観測 / 送信した値。codex-plan-mode)。
161
+ * Hub は backfill 完了後に `pc:collab` として配り、iOS のトグルを実体へ揃える。null は不明。
162
+ */
163
+ collaborationMode?: CodexCollaborationMode | null;
106
164
  }
107
165
  export interface CodexAppServerThreadRuntime {
108
166
  openThread(options: CodexAppServerThreadOptions): Promise<CodexThreadClient>;
@@ -125,13 +183,25 @@ export declare class CodexNativeTurnController implements CodexTurnControllerRun
125
183
  private readonly onQuestion;
126
184
  private readonly onQuestionDismiss;
127
185
  private readonly onChatItem;
186
+ private readonly onGoal;
187
+ private readonly onCollaborationMode;
128
188
  private readonly onDisconnect;
129
189
  private readonly onThreadTitle;
130
190
  private readonly log;
131
191
  /** 同文の deprecationNotice は process 内で 1 回だけ記録する(thread を開くたびに届く)。 */
132
192
  private readonly loggedDeprecationNotices;
133
193
  private readonly open;
194
+ /** session ごとの開始中 Promise(threadFor の同時呼び出しを 1 回の open に畳む)。 */
195
+ private readonly opening;
196
+ /**
197
+ * plan に入る直前の model / effort(codex-plan-mode)。thread 接続は購読者ゼロで畳まれるため、接続を
198
+ * 作り直しても default 復帰で元の値へ戻せるよう controller が session 単位で預かる(hub 再起動では失う)。
199
+ */
200
+ private readonly planRestoreBySession;
201
+ /** 開始中(opening)に close を要求された session。開き終わった時点で畳む。 */
202
+ private readonly closeRequestedWhileOpening;
134
203
  private readonly pendingUserInput;
204
+ private rememberPlanRestoreSnapshot;
135
205
  constructor(options: CodexNativeTurnControllerOptions);
136
206
  subscribeSession(options: {
137
207
  session: string;
@@ -147,7 +217,28 @@ export declare class CodexNativeTurnController implements CodexTurnControllerRun
147
217
  effort?: string | null;
148
218
  sandbox?: "read-only" | "workspace-write" | "danger-full-access" | null;
149
219
  approvalPolicy?: CodexAppServerApprovalPolicy | null;
220
+ collaborationMode?: CodexCollaborationMode | null;
150
221
  }): Promise<string>;
222
+ /**
223
+ * 実行中 turn への steer は collaboration mode を変えられない(turn/steer に mode 引数が無い)。iOS の
224
+ * トグルと違う mode の turn が走っている間の送信は、そのまま steer しつつ注記で知らせる(次の新規 turn
225
+ * から反映される)。thread の現 mode が不明な接続(テスト fake / 旧 server)では判定できないので黙る。
226
+ */
227
+ private noteCollaborationModeNotAppliedBySteer;
228
+ /** 目標の読み取り / 設定 / 解除(codex-goal)。結果は通知と同じ経路(onGoal / 注記)にも反映する。 */
229
+ goal(options: CodexGoalOperation): Promise<CodexGoalOperationResult>;
230
+ hasActiveGoal(session: string): boolean;
231
+ /**
232
+ * 目標の現在値を取り込む。状態 / 内容が変わったときだけ 🎯 注記を chat へ出し(進捗だけの更新では
233
+ * 出さない)、必要なら onGoal で Hub へ配る。open 直後の初回読み取りは注記を出さない(履歴側の
234
+ * rollout 注記と二重になるため)。
235
+ */
236
+ private applyGoal;
237
+ /**
238
+ * open 直後に現在の目標を読む(失敗は無視: 旧 server / ephemeral thread は未対応)。threadFor が
239
+ * await するので、購読直後に購読者がいなくなっても hasActiveGoal が「未読」で false を返さない。
240
+ */
241
+ private loadGoal;
151
242
  private generateThreadTitle;
152
243
  private generateThreadTitleWithRetry;
153
244
  interruptTurn(session: string): Promise<void>;
@@ -156,7 +247,13 @@ export declare class CodexNativeTurnController implements CodexTurnControllerRun
156
247
  close(): void;
157
248
  /** iOS の既存 QuestionPromptSheet 回答を native requestUserInput response へ戻す。 */
158
249
  answerQuestion(id: string, answers: QuestionAnswer[]): boolean;
250
+ /**
251
+ * `intent`: "operate"(turn 開始 / 目標操作)は、開いている途中で close 要求が入って畳まれた場合に開き直す
252
+ * (閉じた接続へ turn/start を投げない)。"subscribe" は畳まれた OpenThread をそのまま返す(購読者が
253
+ * 去った後の再 open は無駄で、hub 側が購読者ゼロなら結果を捨てる)。
254
+ */
159
255
  private threadFor;
256
+ private openThreadFor;
160
257
  private handleNotification;
161
258
  /** 子 thread(サブエージェント)のモデル変更を workflow ノードへ反映する。未知の thread は無視。 */
162
259
  private applySubagentModel;
@@ -1,11 +1,13 @@
1
1
  // Codex App Server の長寿命 thread 接続、native approval、turn/start を Tailii へ結線する。
2
2
  import * as net from "node:net";
3
+ import { codexGoalFromWire } from "./codexAppServer.js";
3
4
  import { decodeControlMessage, encodeControlMessage, PROTOCOL_V1, PROTOCOL_V2, } from "../protocol.js";
5
+ import { codexPlanChatOutput } from "./codexPlanItem.js";
4
6
  import { resolveSocketPath } from "../shared/socketPath.js";
5
7
  import { sleep } from "../shared/sleep.js";
6
8
  import { codexItemToolActivities, codexPlanUpdateActivity, toolActivityContentKey, toolActivityMessage, } from "./codexToolActivity.js";
7
9
  import { CodexSubagentTracker } from "./codexSubagentTracker.js";
8
- import { codexAppServerSystemNotice, codexDeprecationNoticeLogLine, codexMcpItemErrorNotice, codexSystemNoticeContentKey, } from "./codexSystemNotice.js";
10
+ import { codexAppServerSystemNotice, codexCollaborationModeSteerNotice, codexDeprecationNoticeLogLine, codexGoalClearedNotice, codexGoalNotice, codexGoalNoticeKey, codexMcpItemErrorNotice, codexSystemNoticeContentKey, } from "./codexSystemNotice.js";
9
11
  const TITLE_GENERATION_MAX_ATTEMPTS = 3;
10
12
  const TITLE_GENERATION_RETRY_BASE_MS = 250;
11
13
  /** 記録済み deprecationNotice 文言の保持上限。 */
@@ -52,13 +54,33 @@ export class CodexNativeTurnController {
52
54
  onQuestion;
53
55
  onQuestionDismiss;
54
56
  onChatItem;
57
+ onGoal;
58
+ onCollaborationMode;
55
59
  onDisconnect;
56
60
  onThreadTitle;
57
61
  log;
58
62
  /** 同文の deprecationNotice は process 内で 1 回だけ記録する(thread を開くたびに届く)。 */
59
63
  loggedDeprecationNotices = new Set();
60
64
  open = new Map();
65
+ /** session ごとの開始中 Promise(threadFor の同時呼び出しを 1 回の open に畳む)。 */
66
+ opening = new Map();
67
+ /**
68
+ * plan に入る直前の model / effort(codex-plan-mode)。thread 接続は購読者ゼロで畳まれるため、接続を
69
+ * 作り直しても default 復帰で元の値へ戻せるよう controller が session 単位で預かる(hub 再起動では失う)。
70
+ */
71
+ planRestoreBySession = new Map();
72
+ /** 開始中(opening)に close を要求された session。開き終わった時点で畳む。 */
73
+ closeRequestedWhileOpening = new Set();
61
74
  pendingUserInput = new Map();
75
+ rememberPlanRestoreSnapshot(session, threadId, thread) {
76
+ const snapshot = thread.planRestoreSnapshot;
77
+ if (snapshot === undefined)
78
+ return;
79
+ if (snapshot === null)
80
+ this.planRestoreBySession.delete(session);
81
+ else
82
+ this.planRestoreBySession.set(session, { threadId, snapshot });
83
+ }
62
84
  constructor(options) {
63
85
  this.appServer = options.appServer;
64
86
  this.approvalBroker = options.approvalBroker ?? requestCodexApprovalViaBroker;
@@ -68,12 +90,14 @@ export class CodexNativeTurnController {
68
90
  this.onQuestion = options.onQuestion ?? (() => { });
69
91
  this.onQuestionDismiss = options.onQuestionDismiss ?? (() => { });
70
92
  this.onChatItem = options.onChatItem ?? (() => { });
93
+ this.onGoal = options.onGoal ?? (() => { });
94
+ this.onCollaborationMode = options.onCollaborationMode ?? (() => { });
71
95
  this.onDisconnect = options.onDisconnect ?? (() => { });
72
96
  this.onThreadTitle = options.onThreadTitle ?? (() => { });
73
97
  this.log = options.log ?? (() => { });
74
98
  }
75
99
  async subscribeSession(options) {
76
- const opened = await this.threadFor(options.session, options.threadId, options.cwd);
100
+ const opened = await this.threadFor(options.session, options.threadId, options.cwd, "subscribe");
77
101
  const itemIds = new Set();
78
102
  const contentCounts = new Map();
79
103
  for (const item of opened.thread.initialItems ?? []) {
@@ -87,12 +111,16 @@ export class CodexNativeTurnController {
87
111
  contentCounts.set(key, (contentCounts.get(key) ?? 0) + 1);
88
112
  }
89
113
  }
114
+ // 目標の現在値は購読のたびに配る(既存 thread の再利用 = 開き直し / 再接続 / 別端末でも届く。
115
+ // null も配って、開いていない間に解除された目標の古い表示を消す)。
116
+ this.onGoal(options.session, opened.goal);
90
117
  return {
91
118
  itemIds,
92
119
  contentCounts,
93
120
  liveSubscribed: opened.thread.liveSubscriptionReady !== false,
94
121
  liveSubscriptionError: opened.thread.liveSubscriptionError ?? null,
95
122
  model: opened.thread.model ?? null,
123
+ collaborationMode: opened.thread.collaborationMode ?? null,
96
124
  };
97
125
  }
98
126
  async startTurn(options) {
@@ -111,6 +139,7 @@ export class CodexNativeTurnController {
111
139
  if (activeTurnId !== null) {
112
140
  try {
113
141
  await opened.thread.steerTurn(activeTurnId, options.text, options.clientUserMessageId);
142
+ this.noteCollaborationModeNotAppliedBySteer(options.session, opened, activeTurnId, options.collaborationMode);
114
143
  this.generateThreadTitle(opened, options.session, options.text);
115
144
  return activeTurnId;
116
145
  }
@@ -121,6 +150,7 @@ export class CodexNativeTurnController {
121
150
  if (currentTurnId !== null && currentTurnId !== activeTurnId) {
122
151
  opened.activeTurnId = currentTurnId;
123
152
  await opened.thread.steerTurn(currentTurnId, options.text, options.clientUserMessageId);
153
+ this.noteCollaborationModeNotAppliedBySteer(options.session, opened, currentTurnId, options.collaborationMode);
124
154
  this.generateThreadTitle(opened, options.session, options.text);
125
155
  return currentTurnId;
126
156
  }
@@ -131,7 +161,9 @@ export class CodexNativeTurnController {
131
161
  throw error;
132
162
  }
133
163
  }
134
- const turnId = await opened.thread.startTurn(options.text, options.clientUserMessageId, options.effort, options.sandbox, options.approvalPolicy);
164
+ // 実行中 turn への steer は入力を足すだけで collaboration mode は変えられない(上の steer 経路)。
165
+ // 新規 turn だけが iOS のトグルどおりの mode で始まる。
166
+ const turnId = await opened.thread.startTurn(options.text, options.clientUserMessageId, options.effort, options.sandbox, options.approvalPolicy, options.collaborationMode ?? null);
135
167
  opened.activeTurnId = turnId;
136
168
  this.generateThreadTitle(opened, options.session, options.text);
137
169
  return turnId;
@@ -141,6 +173,93 @@ export class CodexNativeTurnController {
141
173
  throw error;
142
174
  }
143
175
  }
176
+ /**
177
+ * 実行中 turn への steer は collaboration mode を変えられない(turn/steer に mode 引数が無い)。iOS の
178
+ * トグルと違う mode の turn が走っている間の送信は、そのまま steer しつつ注記で知らせる(次の新規 turn
179
+ * から反映される)。thread の現 mode が不明な接続(テスト fake / 旧 server)では判定できないので黙る。
180
+ */
181
+ noteCollaborationModeNotAppliedBySteer(session, opened, turnId, requested) {
182
+ if (!requested)
183
+ return;
184
+ const current = opened.thread.collaborationMode;
185
+ if (current === undefined || current === null || current === requested)
186
+ return;
187
+ const notice = codexCollaborationModeSteerNotice(requested, turnId);
188
+ this.onChatItem({ session, itemId: notice.itemId, payload: notice.payload });
189
+ }
190
+ /** 目標の読み取り / 設定 / 解除(codex-goal)。結果は通知と同じ経路(onGoal / 注記)にも反映する。 */
191
+ async goal(options) {
192
+ const opened = await this.threadFor(options.session, options.threadId, options.cwd);
193
+ const thread = opened.thread;
194
+ if (options.action === "get") {
195
+ if (thread.goalGet === undefined)
196
+ throw new Error("この Codex App Server は目標(goal)に対応していません");
197
+ const goal = await thread.goalGet();
198
+ this.applyGoal(options.session, opened, goal, { publishState: true });
199
+ return { goal };
200
+ }
201
+ if (options.action === "clear") {
202
+ if (thread.goalClear === undefined)
203
+ throw new Error("この Codex App Server は目標(goal)に対応していません");
204
+ const cleared = await thread.goalClear();
205
+ this.applyGoal(options.session, opened, null, { publishState: true });
206
+ return { goal: null, cleared };
207
+ }
208
+ if (thread.goalSet === undefined)
209
+ throw new Error("この Codex App Server は目標(goal)に対応していません");
210
+ const goal = await thread.goalSet({
211
+ ...(options.objective !== undefined ? { objective: options.objective } : {}),
212
+ ...(options.status !== undefined ? { status: options.status } : {}),
213
+ ...(options.tokenBudget !== undefined ? { tokenBudget: options.tokenBudget } : {}),
214
+ });
215
+ this.applyGoal(options.session, opened, goal, { publishState: true });
216
+ return { goal };
217
+ }
218
+ hasActiveGoal(session) {
219
+ return this.open.get(session)?.goal?.status === "active";
220
+ }
221
+ /**
222
+ * 目標の現在値を取り込む。状態 / 内容が変わったときだけ 🎯 注記を chat へ出し(進捗だけの更新では
223
+ * 出さない)、必要なら onGoal で Hub へ配る。open 直後の初回読み取りは注記を出さない(履歴側の
224
+ * rollout 注記と二重になるため)。
225
+ */
226
+ applyGoal(session, opened, goal, options) {
227
+ const key = codexGoalNoticeKey(goal);
228
+ const changed = opened.goalNoticeKey !== key;
229
+ const previousCreatedAt = opened.goal?.createdAt ?? null;
230
+ opened.goal = goal;
231
+ // 注記は「変化」だけ。初回読み取り(履歴の rollout 注記と二重になる)と、目標を一度も見ていない
232
+ // 状態での「解除」(意味が無い)は出さない。
233
+ const knownBefore = opened.goalNoticeKey !== null;
234
+ if (changed && options.initial !== true && (goal !== null || knownBefore)) {
235
+ const notice = goal === null ? codexGoalClearedNotice(previousCreatedAt) : codexGoalNotice(goal);
236
+ this.onChatItem({ session, itemId: notice.itemId, payload: notice.payload });
237
+ }
238
+ opened.goalNoticeKey = key;
239
+ if (options.publishState)
240
+ this.onGoal(session, goal);
241
+ }
242
+ /**
243
+ * open 直後に現在の目標を読む(失敗は無視: 旧 server / ephemeral thread は未対応)。threadFor が
244
+ * await するので、購読直後に購読者がいなくなっても hasActiveGoal が「未読」で false を返さない。
245
+ */
246
+ async loadGoal(session, opened) {
247
+ if (opened.thread.goalGet === undefined)
248
+ return;
249
+ try {
250
+ const goal = await opened.thread.goalGet();
251
+ if (this.open.get(session) !== opened)
252
+ return;
253
+ // 通知(thread/goal/updated)が先に届いて確定済みなら上書きしない。
254
+ if (opened.goalNoticeKey !== null)
255
+ return;
256
+ // 配信は購読確立(subscribeSession)側で行う: 開き直しで既存 thread を再利用する場合も必ず 1 回届く。
257
+ this.applyGoal(session, opened, goal, { publishState: false, initial: true });
258
+ }
259
+ catch (error) {
260
+ this.log(`Codex 目標の読み取りに失敗(無視) session=${session}: ${String(error)}`);
261
+ }
262
+ }
144
263
  generateThreadTitle(opened, session, prompt) {
145
264
  if (!opened.titleGenerationPending || this.appServer.generateThreadTitle === undefined)
146
265
  return;
@@ -220,8 +339,13 @@ export class CodexNativeTurnController {
220
339
  }
221
340
  closeSession(session) {
222
341
  const opened = this.open.get(session);
223
- if (opened === undefined)
342
+ if (opened === undefined) {
343
+ // 開いている途中なら、開き終わった時点で畳む(openThread の完了を待たずに孤児接続を作らない)。
344
+ if (this.opening.has(session))
345
+ this.closeRequestedWhileOpening.add(session);
224
346
  return;
347
+ }
348
+ this.rememberPlanRestoreSnapshot(session, opened.threadId, opened.thread);
225
349
  this.open.delete(session);
226
350
  opened.thread.close();
227
351
  this.resolvePendingQuestionsForSession(session);
@@ -261,12 +385,54 @@ export class CodexNativeTurnController {
261
385
  this.onQuestionDismiss(pending.session, id);
262
386
  return true;
263
387
  }
264
- async threadFor(session, threadId, cwd) {
388
+ /**
389
+ * `intent`: "operate"(turn 開始 / 目標操作)は、開いている途中で close 要求が入って畳まれた場合に開き直す
390
+ * (閉じた接続へ turn/start を投げない)。"subscribe" は畳まれた OpenThread をそのまま返す(購読者が
391
+ * 去った後の再 open は無駄で、hub 側が購読者ゼロなら結果を捨てる)。
392
+ */
393
+ async threadFor(session, threadId, cwd, intent = "operate") {
394
+ // 開始中(openThread → 目標読み取り → サブエージェント復元)の同じ session は完了を共有する。
395
+ // 会話オープンでは hub の購読と iOS の目標 get がほぼ同時に届くため、共有しないと openThread が
396
+ // 2 回走って片方の接続が閉じられずに残り、その切断が正しい方の OpenThread まで消す。
397
+ // 待った後は必ず opening を見直す: 別 threadId の待ち手が先に起きて新しい open を始めていることが
398
+ // あり、1 回だけの確認だと同じ threadId の open が 2 本走る。
399
+ for (;;) {
400
+ const pending = this.opening.get(session);
401
+ if (pending === undefined)
402
+ break;
403
+ let opened = null;
404
+ try {
405
+ opened = await pending.task;
406
+ }
407
+ catch (error) {
408
+ // 同じ thread の open が失敗したなら、待ち手全員が順番に開き直して待ち時間を積み上げない
409
+ // (daemon 不調時に hub の RPC 予算を超える)。別 thread の失敗なら自分で開く。
410
+ if (pending.threadId === threadId)
411
+ throw error;
412
+ }
413
+ // 開き終わった直後に閉じられた / 別 thread へ切り替わった OpenThread は返さない。
414
+ if (opened !== null && opened.threadId === threadId && this.open.get(session) === opened)
415
+ return opened;
416
+ }
265
417
  const existing = this.open.get(session);
266
418
  if (existing?.threadId === threadId)
267
419
  return existing;
268
420
  if (existing !== undefined)
269
421
  this.closeSession(session);
422
+ this.closeRequestedWhileOpening.delete(session);
423
+ const task = this.openThreadFor(session, threadId, cwd).finally(() => {
424
+ if (this.opening.get(session)?.task === task)
425
+ this.opening.delete(session);
426
+ });
427
+ this.opening.set(session, { threadId, task });
428
+ const opened = await task;
429
+ if (intent === "operate" && this.open.get(session) !== opened) {
430
+ // 開いている途中に close 要求(購読者が去った等)で畳まれた。操作の相手が要るので開き直す。
431
+ return this.threadFor(session, threadId, cwd, intent);
432
+ }
433
+ return opened;
434
+ }
435
+ async openThreadFor(session, threadId, cwd) {
270
436
  const items = new Map();
271
437
  const bufferedNotifications = [];
272
438
  let notificationTargetReady = false;
@@ -285,12 +451,18 @@ export class CodexNativeTurnController {
285
451
  const current = this.open.get(session);
286
452
  if (current?.threadId !== threadId)
287
453
  return;
454
+ this.rememberPlanRestoreSnapshot(session, threadId, current.thread);
288
455
  this.open.delete(session);
289
456
  this.resolvePendingQuestionsForSession(session);
290
457
  this.onProcessing(session, "done");
291
458
  this.onDisconnect(session, error);
292
459
  },
293
460
  });
461
+ // plan 直前の model / effort は接続をまたいで復元する(接続は購読者ゼロで畳まれるため)。
462
+ const planRestore = this.planRestoreBySession.get(session);
463
+ if (planRestore !== undefined && planRestore.threadId === threadId) {
464
+ thread.seedPlanRestoreSnapshot?.(planRestore.snapshot);
465
+ }
294
466
  const opened = {
295
467
  threadId,
296
468
  cwd,
@@ -303,10 +475,22 @@ export class CodexNativeTurnController {
303
475
  planSeq: 0,
304
476
  subagents: new CodexSubagentTracker(threadId),
305
477
  subagentSeq: 0,
478
+ goal: null,
479
+ goalNoticeKey: null,
306
480
  };
307
481
  this.open.set(session, opened);
482
+ // 開いている間に close が要求されていた(購読者が去った等)なら、開き終わった接続を残さず畳む。
483
+ if (this.closeRequestedWhileOpening.delete(session)) {
484
+ this.closeSession(session);
485
+ return opened;
486
+ }
308
487
  if (opened.activeTurnId !== null)
309
488
  this.onProcessing(session, "active");
489
+ // 目標は rollout に現在値が無い(SQLite 保存)ため、開くたびに App Server から読む(失敗無視)。
490
+ // Hub の close 判定(hasActiveGoal)が open 直後から正しい値を返すよう、ここで待つ。
491
+ await this.loadGoal(session, opened);
492
+ if (this.open.get(session) !== opened)
493
+ return opened;
310
494
  const restoredSubagents = [];
311
495
  for (const item of thread.initialItems ?? []) {
312
496
  restoredSubagents.push(...opened.subagents.ingestItem(item, Date.now()));
@@ -464,25 +648,46 @@ export class CodexNativeTurnController {
464
648
  const settingsThreadId = params?.["threadId"];
465
649
  const settings = asRecord(params?.["threadSettings"]);
466
650
  const model = settings?.["model"];
651
+ const ownThread = typeof settingsThreadId !== "string" || settingsThreadId === threadId;
467
652
  if (typeof model === "string" && model.length > 0) {
468
- if (typeof settingsThreadId !== "string" || settingsThreadId === threadId) {
653
+ if (ownThread) {
469
654
  this.onModel(session, model);
470
655
  }
471
656
  else {
472
657
  this.applySubagentModel(session, threadId, settingsThreadId, model);
473
658
  }
474
659
  }
660
+ if (ownThread && settings !== null && current?.threadId === threadId) {
661
+ // collaboration mode / effort / モデルの実体を turn 開始側へ追従させる(codex-plan-mode)。
662
+ current.thread.noteThreadSettings?.(settings);
663
+ const mode = asRecord(settings["collaborationMode"])?.["mode"];
664
+ if (mode === "plan" || mode === "default")
665
+ this.onCollaborationMode(session, mode);
666
+ }
475
667
  }
476
668
  if (notification.method === "model/rerouted") {
477
669
  const reroutedThreadId = params?.["threadId"];
478
670
  const model = params?.["toModel"];
479
671
  if (typeof reroutedThreadId === "string" && typeof model === "string" && model.length > 0) {
480
- if (reroutedThreadId === threadId)
672
+ if (reroutedThreadId === threadId) {
673
+ // 表示だけ更新する。振り替え先を settings.model に採らない(default 毎回明示で恒久化しないため)。
481
674
  this.onModel(session, model);
482
- else
675
+ }
676
+ else {
483
677
  this.applySubagentModel(session, threadId, reroutedThreadId, model);
678
+ }
484
679
  }
485
680
  }
681
+ if (notification.method === "thread/goal/updated" && lifecycleMatchesThread) {
682
+ const goal = codexGoalFromWire(params?.["goal"]);
683
+ if (goal !== null && current?.threadId === threadId) {
684
+ this.applyGoal(session, current, goal, { publishState: true });
685
+ }
686
+ }
687
+ if (notification.method === "thread/goal/cleared" && lifecycleMatchesThread) {
688
+ if (current?.threadId === threadId)
689
+ this.applyGoal(session, current, null, { publishState: true });
690
+ }
486
691
  if (notification.method === "thread/tokenUsage/updated") {
487
692
  const tokenUsage = asRecord(params?.["tokenUsage"]);
488
693
  const last = asRecord(tokenUsage?.["last"]);
@@ -616,6 +821,11 @@ export function codexItemToChatOutput(item) {
616
821
  return { type: "chat_output", v: PROTOCOL_V1, streamId: `codex-item-${id}`,
617
822
  role: "assistant", text, eof: true };
618
823
  }
824
+ if (type === "plan") {
825
+ // プランモードの提案プラン(codex-plan-mode)。rollout の item_completed/Plan と同じ streamId / 本文。
826
+ const text = item["text"];
827
+ return typeof text === "string" ? codexPlanChatOutput(id, text) : null;
828
+ }
619
829
  return null;
620
830
  }
621
831
  export function chatContentKey(payload) {