tailii-host 0.6.0 → 0.7.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 (94) hide show
  1. package/dist/approval/hook.js +11 -1
  2. package/dist/approval/hook.js.map +1 -1
  3. package/dist/backend/herdr.d.ts +41 -6
  4. package/dist/backend/herdr.js +111 -15
  5. package/dist/backend/herdr.js.map +1 -1
  6. package/dist/backend/sessionBackend.d.ts +10 -3
  7. package/dist/backend/sessionBackend.js +5 -2
  8. package/dist/backend/sessionBackend.js.map +1 -1
  9. package/dist/backend/tmux.d.ts +45 -1
  10. package/dist/backend/tmux.js +95 -11
  11. package/dist/backend/tmux.js.map +1 -1
  12. package/dist/chat/chatTailController.d.ts +1 -1
  13. package/dist/chat/chatTailController.js +16 -33
  14. package/dist/chat/chatTailController.js.map +1 -1
  15. package/dist/chat/imageService.d.ts +9 -1
  16. package/dist/chat/imageService.js +39 -0
  17. package/dist/chat/imageService.js.map +1 -1
  18. package/dist/chat/subagentTailer.js +23 -4
  19. package/dist/chat/subagentTailer.js.map +1 -1
  20. package/dist/chat/transcriptTailer.d.ts +38 -13
  21. package/dist/chat/transcriptTailer.js +240 -17
  22. package/dist/chat/transcriptTailer.js.map +1 -1
  23. package/dist/codex/codexAppServer.d.ts +54 -9
  24. package/dist/codex/codexAppServer.js +495 -76
  25. package/dist/codex/codexAppServer.js.map +1 -1
  26. package/dist/codex/codexNativeTurnController.d.ts +22 -1
  27. package/dist/codex/codexNativeTurnController.js +57 -8
  28. package/dist/codex/codexNativeTurnController.js.map +1 -1
  29. package/dist/codex/codexRolloutTailer.d.ts +2 -0
  30. package/dist/codex/codexRolloutTailer.js +78 -13
  31. package/dist/codex/codexRolloutTailer.js.map +1 -1
  32. package/dist/codex/codexSessionStore.js +54 -16
  33. package/dist/codex/codexSessionStore.js.map +1 -1
  34. package/dist/codex/codexSubagentTracker.d.ts +13 -0
  35. package/dist/codex/codexSubagentTracker.js +55 -1
  36. package/dist/codex/codexSubagentTracker.js.map +1 -1
  37. package/dist/codex/codexSystemNotice.d.ts +2 -0
  38. package/dist/codex/codexSystemNotice.js +20 -3
  39. package/dist/codex/codexSystemNotice.js.map +1 -1
  40. package/dist/commands/launch.js +50 -13
  41. package/dist/commands/launch.js.map +1 -1
  42. package/dist/engine/context.d.ts +32 -1
  43. package/dist/engine/context.js +39 -3
  44. package/dist/engine/context.js.map +1 -1
  45. package/dist/engine/engine.d.ts +5 -1
  46. package/dist/engine/engine.js +89 -4
  47. package/dist/engine/engine.js.map +1 -1
  48. package/dist/engine/handlers/codex.js +37 -0
  49. package/dist/engine/handlers/codex.js.map +1 -1
  50. package/dist/engine/handlers/core.js +14 -1
  51. package/dist/engine/handlers/core.js.map +1 -1
  52. package/dist/engine/handlers/mode.js +64 -1
  53. package/dist/engine/handlers/mode.js.map +1 -1
  54. package/dist/engine/handlers/session.js +17 -33
  55. package/dist/engine/handlers/session.js.map +1 -1
  56. package/dist/engine/handlers/workspace.js +22 -1
  57. package/dist/engine/handlers/workspace.js.map +1 -1
  58. package/dist/hub/effortDialog.d.ts +15 -0
  59. package/dist/hub/effortDialog.js +60 -16
  60. package/dist/hub/effortDialog.js.map +1 -1
  61. package/dist/hub/engineRelaySocket.d.ts +4 -0
  62. package/dist/hub/engineRelaySocket.js +6 -1
  63. package/dist/hub/engineRelaySocket.js.map +1 -1
  64. package/dist/hub/hubDaemon.js +22 -3
  65. package/dist/hub/hubDaemon.js.map +1 -1
  66. package/dist/hub/hubProtocol.d.ts +10 -0
  67. package/dist/hub/hubProtocol.js +12 -1
  68. package/dist/hub/hubProtocol.js.map +1 -1
  69. package/dist/hub/questionInjection.d.ts +12 -0
  70. package/dist/hub/questionInjection.js +70 -3
  71. package/dist/hub/questionInjection.js.map +1 -1
  72. package/dist/hub/sessionHub.d.ts +78 -4
  73. package/dist/hub/sessionHub.js +281 -67
  74. package/dist/hub/sessionHub.js.map +1 -1
  75. package/dist/protocol/decode.js +74 -17
  76. package/dist/protocol/decode.js.map +1 -1
  77. package/dist/protocol/encode.js +2 -0
  78. package/dist/protocol/encode.js.map +1 -1
  79. package/dist/protocol/messages.d.ts +65 -1
  80. package/dist/services/fileService.d.ts +15 -0
  81. package/dist/services/fileService.js +176 -14
  82. package/dist/services/fileService.js.map +1 -1
  83. package/dist/services/officialApps.d.ts +12 -1
  84. package/dist/services/officialApps.js +145 -30
  85. package/dist/services/officialApps.js.map +1 -1
  86. package/dist/services/previewServer.js +15 -6
  87. package/dist/services/previewServer.js.map +1 -1
  88. package/dist/sessions/transcriptPath.d.ts +3 -0
  89. package/dist/sessions/transcriptPath.js +15 -0
  90. package/dist/sessions/transcriptPath.js.map +1 -0
  91. package/dist/shared/clientBuild.d.ts +44 -0
  92. package/dist/shared/clientBuild.js +116 -0
  93. package/dist/shared/clientBuild.js.map +1 -0
  94. package/package.json +3 -3
@@ -1,6 +1,6 @@
1
1
  // codexAppServer.ts
2
2
  // Tailii host が共有 Codex App Server を再利用・起動し、thread ID を先に確定する最小クライアント。
3
- import { spawn } from "node:child_process";
3
+ import { execFile, spawn } from "node:child_process";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import * as fs from "node:fs";
6
6
  import * as net from "node:net";
@@ -13,6 +13,14 @@ const THREAD_TITLE_PROMPT_MAX_LENGTH = 2_000;
13
13
  const THREAD_TITLE_MAX_LENGTH = 36;
14
14
  const THREAD_TITLE_FALLBACK_MAX_LENGTH = 60;
15
15
  const DEFAULT_TITLE_GENERATION_TIMEOUT_MS = 30_000;
16
+ /** thread/items/list の 1 ページ当たり件数(server は 1..100 に clamp する。turns/list は常に limit 1)。 */
17
+ const THREAD_HISTORY_PAGE_LIMIT = 100;
18
+ /** 履歴ページングの上限ページ数(100 件 × 2000 ページ)。cursor が進まない版でも無限ループしない。 */
19
+ const THREAD_HISTORY_MAX_PAGES = 2_000;
20
+ /** 全履歴の取得で送信経路を塞がないための総時間上限。超過時は別接続で制御を復旧する。 */
21
+ const THREAD_HISTORY_SNAPSHOT_TIMEOUT_MS = 3_000;
22
+ /** タイトル生成で最初の user prompt を探す上限ページ数(paginated の thread/items/list)。 */
23
+ const THREAD_TITLE_PROMPT_MAX_PAGES = 4;
16
24
  const THREAD_TITLE_OUTPUT_SCHEMA = {
17
25
  type: "object",
18
26
  properties: {
@@ -98,11 +106,12 @@ class WebSocketCodexAppServerConnection {
98
106
  });
99
107
  }
100
108
  async initialize() {
101
- await this.request("initialize", {
109
+ const response = await this.request("initialize", {
102
110
  clientInfo: { name: "tailii_host", title: "Tailii Host", version: "0.1.1" },
103
111
  capabilities: { experimentalApi: true },
104
112
  });
105
113
  this.notify("initialized", undefined);
114
+ return response;
106
115
  }
107
116
  request(method, params, timeoutMs) {
108
117
  const id = this.nextId;
@@ -212,6 +221,8 @@ export class CodexAppServerThread {
212
221
  liveSubscriptionReady;
213
222
  connection;
214
223
  cwd;
224
+ liveSubscriptionError;
225
+ model;
215
226
  /** 読み取り専用の readiness 確認だけは、共有 App Server の一時的な混雑を自動回復する。 */
216
227
  // 初回5秒 + 再試行2秒に抑え、上位の15秒 Hub RPC に turn/start の予算を残す。
217
228
  static activeTurnReadMaxAttempts = 2;
@@ -220,17 +231,28 @@ export class CodexAppServerThread {
220
231
  supportsSteerClientUserMessageId = null;
221
232
  constructor(threadId, initialItems, initialActiveTurnId,
222
233
  /**
223
- * この接続で thread/resume(または作成元 bootstrap)が成功し、live 通知を受け取れるか。
224
- * rollout 未生成の新規 thread は resume に失敗するため false。呼び出し側は初回 turn の
225
- * 表示を rollout tail へフォールバックし、通知が来ない接続を live 権威にしない。
234
+ * この接続で thread/resume と履歴スナップショットの読み取り(または作成元 bootstrap)が
235
+ * 成功し、live 通知を受け取れるか。turn 履歴が未生成の新規 thread は false。呼び出し側は
236
+ * 履歴取得がタイムアウトした場合も false。表示は rollout tail へフォールバックし、
237
+ * この接続を live 本文の権威にしない(turn 操作と承認は接続を維持する)。
226
238
  */
227
- liveSubscriptionReady, connection, cwd) {
239
+ liveSubscriptionReady, connection, cwd,
240
+ /** liveSubscriptionReady=false の理由(履歴読み取りの失敗文言)。ログ診断用。 */
241
+ liveSubscriptionError = null,
242
+ /**
243
+ * 購読時点で thread に設定されているモデル(thread/resume または作成元 thread/start の
244
+ * 応答 `thread.model`)。null は応答に含まれない旧 App Server。Hub はこれを `pc:model`
245
+ * として配信し、会話を開いていない間に変更されたモデルも開き直しで反映する。
246
+ */
247
+ model = null) {
228
248
  this.threadId = threadId;
229
249
  this.initialItems = initialItems;
230
250
  this.initialActiveTurnId = initialActiveTurnId;
231
251
  this.liveSubscriptionReady = liveSubscriptionReady;
232
252
  this.connection = connection;
233
253
  this.cwd = cwd;
254
+ this.liveSubscriptionError = liveSubscriptionError;
255
+ this.model = model;
234
256
  }
235
257
  async startTurn(text, clientUserMessageId, effort, sandbox, approvalPolicy) {
236
258
  if (text.length === 0)
@@ -258,29 +280,43 @@ export class CodexAppServerThread {
258
280
  async readActiveTurnId() {
259
281
  for (let attempt = 1;; attempt += 1) {
260
282
  try {
261
- const response = await this.connection.request("thread/read", {
262
- threadId: this.threadId,
263
- includeTurns: true,
264
- }, attempt === 1 ? undefined : CodexAppServerThread.activeTurnReadRetryTimeoutMs);
265
- return extractActiveTurnId(response);
283
+ // 最新 turn だけを読む(turn は直列なので、実行中なら必ず末尾)。全履歴 hydration
284
+ // (thread/read includeTurns:true)は paginated thread で非推奨のため使わない。
285
+ const turns = await listThreadTurns(this.connection, this.threadId, {
286
+ limit: 1,
287
+ sortDirection: "desc",
288
+ itemsView: "notLoaded",
289
+ all: false,
290
+ timeoutMs: attempt === 1 ? undefined : CodexAppServerThread.activeTurnReadRetryTimeoutMs,
291
+ });
292
+ return activeTurnIdOfTurns(turns);
266
293
  }
267
294
  catch (error) {
268
- // 新規 thread の最初の rollout 行がまだ無い間は、idle とは断定できない。
269
- // controller は手元の ID を維持するか、初回 turn/start へ進む。
270
- if (isUnmaterializedThreadError(error, this.threadId))
271
- return undefined;
272
- // thread/read は副作用が無い。未 materialize thread の初回送信と
295
+ // thread/turns/list は副作用が無い。未 materialize thread の初回送信と
273
296
  // 別セッションの処理が共有 App Server 上で重なった場合のみ、
274
297
  // 5秒 timeout を有界リトライする。turn/start / turn/steer は受理済みか
275
298
  // 不明なため、この自動リトライの対象にしない。
276
- if (!isRequestTimeout(error, "thread/read") ||
277
- attempt >= CodexAppServerThread.activeTurnReadMaxAttempts) {
278
- throw error;
299
+ if (isRequestTimeout(error, "thread/turns/list")) {
300
+ if (attempt >= CodexAppServerThread.activeTurnReadMaxAttempts)
301
+ throw error;
302
+ continue;
279
303
  }
304
+ // 新規 thread の最初の turn 記録がまだ無い間は、idle とは断定できない。
305
+ // controller は手元の ID を維持するか、初回 turn/start へ進む。判定は既知文言に加えて
306
+ // thread メタデータの可読性(文言非依存)で行う。判定文言の網羅漏れで新規会話が
307
+ // 全滅した実障害(2026-09-06)の再発防止。
308
+ if (await confirmThreadHistoryUnavailable(this.connection, this.threadId, error)) {
309
+ return undefined;
310
+ }
311
+ throw error;
280
312
  }
281
313
  }
282
314
  }
283
- /** 親履歴で欠落する sub-agent の終端状態を、子 thread 自身から読み直す。 */
315
+ /**
316
+ * 親履歴で欠落する sub-agent の終端状態を、子 thread 自身から読み直す。
317
+ * Thread の `model`(ロード中は現在の設定値、未ロードなら最後に永続化された値)も返し、
318
+ * 再オープン時に spawn item へ model が無い版でも実行モデルを復元できるようにする。
319
+ */
284
320
  async readThreadStatus(threadId) {
285
321
  for (let attempt = 1; attempt <= CodexAppServerThread.activeTurnReadMaxAttempts; attempt += 1) {
286
322
  try {
@@ -289,9 +325,11 @@ export class CodexAppServerThread {
289
325
  if (thread === null || thread["status"] === undefined)
290
326
  return undefined;
291
327
  const timestampMs = codexTimestampMs(thread["createdAt"] ?? thread["updatedAt"] ?? thread["recencyAt"]);
328
+ const model = thread["model"];
292
329
  return {
293
330
  status: thread["status"],
294
331
  ...(timestampMs === undefined ? {} : { timestampMs }),
332
+ ...(typeof model === "string" && model.length > 0 ? { model } : {}),
295
333
  };
296
334
  }
297
335
  catch (error) {
@@ -393,6 +431,19 @@ async function readCodexSecurityDefaults(connection, cwd) {
393
431
  : null;
394
432
  return { approvalPolicy, approvalsReviewer, sandbox };
395
433
  }
434
+ /**
435
+ * 利用者設定(config.toml 階層)の既定モデル。config/read の `config.model` で、TUI の
436
+ * `/model` で保存した値も含む現在値を読む。未設定・旧 App Server・失敗時は null。
437
+ */
438
+ async function readCodexConfiguredModel(connection) {
439
+ try {
440
+ const response = objectRecord(await connection.request("config/read", { includeLayers: false }));
441
+ return stringValue(objectRecord(response?.["config"])?.["model"]);
442
+ }
443
+ catch {
444
+ return null;
445
+ }
446
+ }
396
447
  function objectRecord(value) {
397
448
  return typeof value === "object" && value !== null && !Array.isArray(value)
398
449
  ? value
@@ -422,6 +473,13 @@ export class CodexAppServerManager {
422
473
  startupLockPath;
423
474
  /** 最初の turn 前(rollout 未作成)の thread を生存させる作成元購読。openThread が引き継ぐ。 */
424
475
  bootstrapConnections = new Map();
476
+ /** thread/start 応答の model。bootstrap 接続を引き継ぐ openThread が購読時のモデルとして返す。 */
477
+ bootstrapThreadModels = new Map();
478
+ log;
479
+ readCliVersion;
480
+ cliVersionPromise = null;
481
+ /** 版ずれは同じ組み合わせにつき 1 回だけログする。 */
482
+ lastVersionMismatch = null;
425
483
  constructor(options = {}) {
426
484
  this.codexHome = options.codexHome ?? path.join(os.homedir(), ".codex");
427
485
  this.codexPath = options.codexPath ?? "codex";
@@ -436,6 +494,32 @@ export class CodexAppServerManager {
436
494
  this.titleGenerationTimeoutMs =
437
495
  options.titleGenerationTimeoutMs ?? DEFAULT_TITLE_GENERATION_TIMEOUT_MS;
438
496
  this.startupLockPath = path.join(path.dirname(this.socketPath), "tailii-start.lock");
497
+ this.log = options.log ?? null;
498
+ this.readCliVersion = options.cliVersion ?? (() => probeCodexCliVersion(this.codexPath));
499
+ }
500
+ /**
501
+ * initialize 応答の `userAgent`(`<client>/<server 版> (...)`)から daemon の版を読み、
502
+ * `codex --version` と食い違えばログに残す。共有 daemon は detached spawn で生き残るため、
503
+ * codex 更新後も旧バイナリが App Server を提供し続ける(2026-07-23 実障害)。他クライアントも
504
+ * 使う daemon を勝手には再起動せず、原因究明の手掛かりとして版ずれを可視化する。
505
+ */
506
+ noteServerVersion(initializeResponse) {
507
+ if (this.log === null)
508
+ return;
509
+ const serverVersion = extractServerVersion(initializeResponse);
510
+ if (serverVersion === null)
511
+ return;
512
+ this.cliVersionPromise ??= this.readCliVersion().catch(() => null);
513
+ void this.cliVersionPromise.then((cliVersion) => {
514
+ if (cliVersion === null || cliVersion === serverVersion)
515
+ return;
516
+ const key = `${serverVersion}->${cliVersion}`;
517
+ if (this.lastVersionMismatch === key)
518
+ return;
519
+ this.lastVersionMismatch = key;
520
+ this.log?.(`Codex App Server daemon の版 ${serverVersion} が codex CLI ${cliVersion} と一致しない` +
521
+ "(旧 daemon が残っている可能性。App Server 経由の操作が失敗するなら daemon を kill して再 spawn)");
522
+ });
439
523
  }
440
524
  /** serverを再利用または排他的に起動し、利用可能になるまで待つ。 */
441
525
  async ensureRunning() {
@@ -480,7 +564,7 @@ export class CodexAppServerManager {
480
564
  let connection = null;
481
565
  try {
482
566
  connection = await this.connect(this.socketPath, requestTimeoutMs);
483
- await connection.initialize();
567
+ this.noteServerVersion(await connection.initialize());
484
568
  return connection;
485
569
  }
486
570
  catch {
@@ -589,6 +673,7 @@ export class CodexAppServerManager {
589
673
  // remote TUI/thread/resume が "no rollout found" になる。openThread まで保持して引き継ぐ。
590
674
  this.bootstrapConnections.get(threadId)?.close();
591
675
  this.bootstrapConnections.set(threadId, connection);
676
+ this.bootstrapThreadModels.set(threadId, threadModel(response));
592
677
  succeeded = true;
593
678
  return threadId;
594
679
  }
@@ -691,6 +776,17 @@ export class CodexAppServerManager {
691
776
  seenCursors.add(cursor);
692
777
  }
693
778
  } while (cursor !== null);
779
+ // model/list の isDefault は preset(サーバ側の推奨既定)で、利用者が config.toml に
780
+ // 書いた model とは別物。thread/start に model を渡さない新規会話は config の model で
781
+ // 起動するため、一覧に含まれる限りそちらを isDefault にして「既定」の表示と実体を揃える。
782
+ const configuredDefault = await readCodexConfiguredModel(connection);
783
+ const visibleModelIds = new Set(rawModels.flatMap((raw) => {
784
+ if (raw["hidden"] === true)
785
+ return [];
786
+ const id = stringValue(raw["model"]) ?? stringValue(raw["id"]);
787
+ return id === null ? [] : [id];
788
+ }));
789
+ const effectiveDefault = configuredDefault !== null && visibleModelIds.has(configuredDefault) ? configuredDefault : null;
694
790
  // model/list がAPIキャッシュを更新した後に読む。App Serverの公開Model型には
695
791
  // context windowが無いため、同じCodex API応答の永続キャッシュから実効値を結合する。
696
792
  const cachedWindows = readModelContextWindows(path.join(this.codexHome, "models_cache.json"));
@@ -720,7 +816,7 @@ export class CodexAppServerManager {
720
816
  ...(contextWindow !== undefined ? { contextWindow } : {}),
721
817
  ...(defaultReasoningEffort !== null ? { defaultReasoningEffort } : {}),
722
818
  ...(supportedReasoningEfforts.length > 0 ? { supportedReasoningEfforts } : {}),
723
- isDefault: raw["isDefault"] === true,
819
+ isDefault: effectiveDefault !== null ? model === effectiveDefault : raw["isDefault"] === true,
724
820
  }];
725
821
  });
726
822
  }
@@ -728,12 +824,34 @@ export class CodexAppServerManager {
728
824
  connection.close();
729
825
  }
730
826
  }
827
+ /** 指定threadの後続turnで使うモデルをApp Serverの設定APIへ反映する。 */
828
+ async setThreadModel(threadId, model) {
829
+ if (threadId.length === 0)
830
+ throw new Error("Codex thread ID must not be empty");
831
+ if (model.length === 0)
832
+ throw new Error("Codex model must not be empty");
833
+ await this.ensureRunning();
834
+ const connection = await this.connect(this.socketPath);
835
+ try {
836
+ await connection.initialize();
837
+ await connection.request("thread/settings/update", { threadId, model });
838
+ }
839
+ finally {
840
+ connection.close();
841
+ }
842
+ }
731
843
  /** 既存 thread を購読する長寿命接続を開く。turn と native approval はこの接続を流れる。 */
732
844
  async openThread(options) {
733
845
  await this.ensureRunning();
846
+ return this.openThreadAttempt(options, null);
847
+ }
848
+ async openThreadAttempt(options, historyTimeout) {
734
849
  const bootstrap = this.bootstrapConnections.get(options.threadId) ?? null;
735
850
  if (bootstrap !== null)
736
851
  this.bootstrapConnections.delete(options.threadId);
852
+ // 作成元接続を引き継ぐ場合は thread/start 応答の model を購読時のモデルとして使う。
853
+ let model = bootstrap !== null ? this.bootstrapThreadModels.get(options.threadId) ?? null : null;
854
+ this.bootstrapThreadModels.delete(options.threadId);
737
855
  const connection = bootstrap ?? (await this.connect(this.socketPath));
738
856
  const removeNotification = connection.onNotification((notification) => {
739
857
  options.onNotification?.(notification);
@@ -747,39 +865,82 @@ export class CodexAppServerManager {
747
865
  void handler(request).then((result) => connection.respond(request.id, result), (error) => connection.respondError(request.id, -32000, String(error)));
748
866
  });
749
867
  const removeDisconnect = connection.onDisconnect?.((error) => options.onDisconnect?.(error)) ?? (() => { });
868
+ let resumedSuccessfully = false;
750
869
  try {
751
870
  let initialItems = [];
752
871
  let initialActiveTurnId = null;
753
872
  let liveSubscriptionReady = bootstrap !== null;
873
+ let liveSubscriptionError = historyTimeout;
754
874
  if (bootstrap === null) {
755
875
  await connection.initialize();
756
876
  try {
757
- const response = await connection.request("thread/resume", {
877
+ // 全履歴 hydration(excludeTurns:false)は paginated thread で非推奨(codex 0.153.4+ は
878
+ // 呼び出した接続へ deprecationNotice を返す)。resume はメタデータだけにし、
879
+ // Hub の rollout backfill と live 通知の境界に使う履歴 item と実行中 turn は
880
+ // ページ API で読む(readThreadHistorySnapshot)。購読開始(resume)と履歴読み取りの
881
+ // 間に完了した item は snapshot と live 通知の両方に現れるが、Hub は snapshot item を
882
+ // rollout の occurrence と照合して 1 回だけ出す(flushCodexBuffer)。
883
+ const resumed = await connection.request("thread/resume", {
758
884
  threadId: options.threadId,
759
- // Hub の rollout backfill と live 通知の厳密な境界に履歴 item ID を使う。
760
- excludeTurns: false,
885
+ excludeTurns: true,
761
886
  });
762
- initialItems = extractThreadItems(response);
763
- initialActiveTurnId = extractActiveTurnId(response);
764
- liveSubscriptionReady = true;
887
+ resumedSuccessfully = true;
888
+ // thread の現在モデルは resume 応答が権威。履歴読み取りが失敗する未materialize
889
+ // thread(turn 前)でも resume 自体は成立するため、snapshot より先に確定する。
890
+ model = threadModel(resumed) ?? model;
891
+ if (historyTimeout === null) {
892
+ const snapshot = await readThreadHistorySnapshotWithRetry(connection, options.threadId, threadHistoryMode(resumed));
893
+ initialItems = snapshot.items;
894
+ initialActiveTurnId = snapshot.activeTurnId;
895
+ liveSubscriptionReady = true;
896
+ }
897
+ else {
898
+ // 大きい画像・tool 出力を含む履歴は、同じ socket の後続 RPC も塞ぐ。
899
+ // 新しい接続で購読し直し、turn 状態だけ確認する。部分履歴を live の権威に
900
+ // せず rollout fallback を使う。実行中なら controller が start ではなく steer する。
901
+ initialActiveTurnId = activeTurnIdOfTurns(await listThreadTurns(connection, options.threadId, {
902
+ limit: 1,
903
+ sortDirection: "desc",
904
+ itemsView: "notLoaded",
905
+ all: false,
906
+ timeoutMs: THREAD_METADATA_PROBE_TIMEOUT_MS,
907
+ }));
908
+ }
765
909
  }
766
910
  catch (error) {
767
- // thread/start から最初の user turn まで rollout は未作成で、別接続からの
768
- // thread/resume は "no rollout found" になる。ただし共有 App Server 内の
769
- // live thread 自体は存在し、この接続から turn/start を直接送れば materialize
770
- // できる。engine と Session Hub は別プロセスなので、この場合だけ履歴ゼロとして
771
- // 接続を維持し、最初の turn/start へ進ませる。
772
- if (!isUnmaterializedThreadError(error, options.threadId))
911
+ // thread/start から最初の user turn まで turn 履歴は未作成で、別接続からの
912
+ // 履歴読み取りは "no rollout found" / "list_turns is not supported yet" になる。
913
+ // ただし共有 App Server 内の live thread 自体は存在し、この接続から turn/start を
914
+ // 直接送れば materialize できる。engine と Session Hub は別プロセスなので、
915
+ // この場合だけ履歴ゼロとして接続を維持し、最初の turn/start へ進ませる
916
+ // (resume 自体が成立していても live 権威にはせず、従来どおり rollout fallback)。
917
+ // 判定は文言一致に加えてメタデータ読み取りの可否(文言非依存)で行う。
918
+ if (!(await confirmThreadHistoryUnavailable(connection, options.threadId, error))) {
773
919
  throw error;
920
+ }
921
+ liveSubscriptionError = String(error);
922
+ // 正常な turn 前(既知文言)と、materialize 済み thread の履歴読み取り失敗(要調査:
923
+ // 会話を開くたびに rollout fallback へ落ち続ける)を hub.log で区別できるようにする。
924
+ this.log?.(isUnmaterializedThreadError(error, options.threadId)
925
+ ? `Codex thread ${options.threadId} は turn 前の未materialize(正常): 履歴スナップショット無しで rollout fallback`
926
+ : `Codex thread ${options.threadId} の履歴スナップショットを読めず rollout fallback(要調査): ${liveSubscriptionError}`);
774
927
  }
775
928
  }
776
- return new CodexAppServerThread(options.threadId, initialItems, initialActiveTurnId, liveSubscriptionReady, connection, options.cwd ?? null);
929
+ return new CodexAppServerThread(options.threadId, initialItems, initialActiveTurnId, liveSubscriptionReady, connection, options.cwd ?? null, liveSubscriptionError, model);
777
930
  }
778
931
  catch (error) {
779
932
  removeNotification();
780
933
  removeServerRequest();
781
934
  removeDisconnect();
782
935
  connection.close();
936
+ if (historyTimeout === null && resumedSuccessfully && (isRequestTimeout(error, "thread/items/list") ||
937
+ isRequestTimeout(error, "thread/turns/list") ||
938
+ isRequestTimeout(error, "thread/read"))) {
939
+ this.log?.(`Codex thread ${options.threadId} の履歴取得がタイムアウト: ` +
940
+ `新しい接続で turn 操作を復旧し、表示は rollout fallback へ移行: ${String(error)}`);
941
+ // 履歴の再取得・turn の再送はしない。新しい制御接続での復旧は 1 回だけ。
942
+ return this.openThreadAttempt(options, String(error));
943
+ }
783
944
  throw error;
784
945
  }
785
946
  }
@@ -871,15 +1032,16 @@ async function readThreadName(connection, threadId) {
871
1032
  async function readThreadTitleTarget(connection, threadId, fallbackPrompt) {
872
1033
  let response;
873
1034
  try {
1035
+ // 名前はメタデータだけの read で読む(includeTurns:true は paginated thread で非推奨)。
874
1036
  response = await connection.request("thread/read", {
875
1037
  threadId,
876
- includeTurns: true,
1038
+ includeTurns: false,
877
1039
  });
878
1040
  }
879
1041
  catch (error) {
880
1042
  // 作成直後は初回 turn/start 応答後もしばらく rollout が空の場合がある。
881
1043
  // controller から渡された最初の入力を使い、既存 name だけ thread/list で保護する。
882
- if (!isUnmaterializedThreadError(error, threadId))
1044
+ if (!(await confirmThreadHistoryUnavailable(connection, threadId, error)))
883
1045
  throw error;
884
1046
  return {
885
1047
  name: await readThreadNameFromList(connection, threadId),
@@ -895,9 +1057,20 @@ async function readThreadTitleTarget(connection, threadId, fallbackPrompt) {
895
1057
  throw new Error("Codex App Server thread/read returned an invalid thread name");
896
1058
  }
897
1059
  const name = typeof rawName === "string" && rawName.trim().length > 0 ? rawName.trim() : null;
898
- const firstUserPrompt = extractThreadItems(response)
899
- .map(extractUserMessageText)
900
- .find((value) => value !== null && value.trim().length > 0);
1060
+ // 命名済みなら呼び出し側は生成しないので、履歴の読み取り(追加 RPC)はしない。
1061
+ if (name !== null)
1062
+ return { name, prompt: fallbackPrompt };
1063
+ // 最初の user prompt は保存済み履歴を権威にする(TUI attach / resume の競合で
1064
+ // controller の入力が後続メッセージになっていても、新規会話の命名を落とさない)。
1065
+ let firstUserPrompt = null;
1066
+ try {
1067
+ firstUserPrompt = await readFirstUserPrompt(connection, threadId, threadHistoryMode(response));
1068
+ }
1069
+ catch (error) {
1070
+ // 初回 turn/start 応答後も turn 履歴の flush までは読めない場合がある。
1071
+ if (!(await confirmThreadHistoryUnavailable(connection, threadId, error)))
1072
+ throw error;
1073
+ }
901
1074
  return {
902
1075
  name,
903
1076
  prompt: firstUserPrompt ?? fallbackPrompt,
@@ -1068,18 +1241,86 @@ function normalizeFallbackThreadTitle(raw) {
1068
1241
  const title = raw.replaceAll("\n", " ").replaceAll("\r", " ").trim();
1069
1242
  return title.slice(0, THREAD_TITLE_FALLBACK_MAX_LENGTH);
1070
1243
  }
1244
+ /**
1245
+ * codex 0.153.4+ の `historyMode: "paginated"` thread は、turn が 1 件も無い間
1246
+ * `thread/turns/list`(および全履歴 hydration の `thread/read includeTurns:true` /
1247
+ * `thread/resume excludeTurns:false`)を JSON-RPC -32601 でこの文言のまま拒否する
1248
+ * (thread id を含まない)。turn/start 自体は受理され、最初の turn 以降は成功する。
1249
+ */
1250
+ const PAGINATED_THREAD_TURNS_UNAVAILABLE = "list_turns is not supported yet";
1251
+ /**
1252
+ * thread/start 直後(最初の user turn 前)の読み取り失敗を、App Server の版ごとの文言から判定する。
1253
+ * - `no rollout found for thread id <id>`: rollout ファイル未作成
1254
+ * - `thread <id> is not materialized yet; <method> is unavailable before first user message`:
1255
+ * method 部分は `includeTurns` / `thread/turns/list` など版で異なるため前方一致
1256
+ * - `failed to read session metadata ... is empty`: session_meta 行の flush 前
1257
+ * - `list_turns is not supported yet`: paginated thread の turn 前(0.153.4 実測)
1258
+ */
1071
1259
  function isUnmaterializedThreadError(error, threadId) {
1072
1260
  if (!(error instanceof Error))
1073
1261
  return false;
1074
- if (error.message === `no rollout found for thread id ${threadId}`)
1262
+ const { message } = error;
1263
+ if (message === `no rollout found for thread id ${threadId}`)
1264
+ return true;
1265
+ if (message.startsWith(`thread ${threadId} is not materialized yet;`))
1266
+ return true;
1267
+ if (message === PAGINATED_THREAD_TURNS_UNAVAILABLE)
1075
1268
  return true;
1076
- const notMaterializedYet = `thread ${threadId} is not materialized yet; ` +
1077
- "includeTurns is unavailable before first user message";
1078
- if (error.message === notMaterializedYet)
1269
+ return message.includes(threadId) &&
1270
+ message.includes("failed to read session metadata") &&
1271
+ message.endsWith("is empty");
1272
+ }
1273
+ /** initialize 応答の userAgent `tailii_host/0.153.4 (...)` から server の数値版を取り出す。 */
1274
+ function extractServerVersion(initializeResponse) {
1275
+ const userAgent = objectRecord(initializeResponse)?.["userAgent"];
1276
+ if (typeof userAgent !== "string")
1277
+ return null;
1278
+ const match = /^[^\s/]+\/(\d+(?:\.\d+)+)/.exec(userAgent);
1279
+ return match?.[1] ?? null;
1280
+ }
1281
+ /** `codex --version`(例: `codex-cli 0.153.4`)の数値版。取れなければ null。 */
1282
+ function probeCodexCliVersion(codexPath) {
1283
+ return new Promise((resolve) => {
1284
+ execFile(codexPath, ["--version"], { timeout: 5_000 }, (error, stdout) => {
1285
+ if (error) {
1286
+ resolve(null);
1287
+ return;
1288
+ }
1289
+ resolve(/\d+(?:\.\d+)+/.exec(String(stdout))?.[0] ?? null);
1290
+ });
1291
+ });
1292
+ }
1293
+ /** 文言非依存判定で使う、メタデータだけの thread/read の期限。 */
1294
+ const THREAD_METADATA_PROBE_TIMEOUT_MS = 2_000;
1295
+ /**
1296
+ * turn 履歴の読み取り(thread/turns/list。旧: thread/resume / thread/read includeTurns:true)の
1297
+ * 失敗が「turn 履歴が未生成・未対応」なのかを、エラー文言に依存せず判定する。
1298
+ *
1299
+ * 1. 既知の文言(isUnmaterializedThreadError)なら即 true。
1300
+ * 2. timeout は接続が刺さっている可能性があるため false(呼び出し側の有界リトライに委ねる)。
1301
+ * 3. それ以外は、メタデータだけの `thread/read includeTurns:false` を短い期限で試す。
1302
+ * live thread は最初の turn 前でも(rollout 未作成でも)これに応答する一方、存在しない
1303
+ * thread は `thread not loaded` 等で失敗する。応答があれば thread は App Server から
1304
+ * 見えており、履歴 hydration だけが不可=turn/start へ進んでよい状態と判断する。
1305
+ *
1306
+ * App Server の文言は版ごとに変わる(no rollout found → not materialized yet →
1307
+ * list_turns is not supported yet)。文言の網羅漏れで新規会話が全滅しないための安全側判定。
1308
+ */
1309
+ async function confirmThreadHistoryUnavailable(connection, threadId, error) {
1310
+ if (isUnmaterializedThreadError(error, threadId))
1079
1311
  return true;
1080
- return error.message.includes(threadId) &&
1081
- error.message.includes("failed to read session metadata") &&
1082
- error.message.endsWith("is empty");
1312
+ if (!(error instanceof Error))
1313
+ return false;
1314
+ if (error.message.startsWith("Codex App Server request timed out"))
1315
+ return false;
1316
+ try {
1317
+ const response = objectRecord(await connection.request("thread/read", { threadId, includeTurns: false }, THREAD_METADATA_PROBE_TIMEOUT_MS));
1318
+ const thread = objectRecord(response?.["thread"]);
1319
+ return thread !== null && thread["id"] === threadId;
1320
+ }
1321
+ catch {
1322
+ return false;
1323
+ }
1083
1324
  }
1084
1325
  function parseRemoteControlStatus(value) {
1085
1326
  if (typeof value !== "object" || value === null || Array.isArray(value))
@@ -1125,48 +1366,221 @@ function parseRemoteControlPairing(value) {
1125
1366
  expiresAt: expiresAt,
1126
1367
  };
1127
1368
  }
1128
- function extractThreadItems(response) {
1369
+ /** thread/resume・thread/read 応答の `thread.historyMode`。欠落や未知の値は legacy 扱い。 */
1370
+ function threadHistoryMode(response) {
1371
+ const thread = objectRecord(objectRecord(response)?.["thread"]);
1372
+ return thread?.["historyMode"] === "paginated" ? "paginated" : "legacy";
1373
+ }
1374
+ /**
1375
+ * host 側で検出した応答形・上限の異常(data 欠落、未知の item 形、turns 欠落、ページ上限)。
1376
+ * server の一時障害ではなく恒久的なので、読み直しの対象にしない。
1377
+ */
1378
+ class CodexHistoryShapeError extends Error {
1379
+ }
1380
+ /**
1381
+ * cursor ページングの共通ループ。応答形が違う(data が配列でない)版は成功として扱わず throw する
1382
+ * (「履歴ゼロなのに live 権威」という誤った状態を作らない。呼び出し側は未 materialize 判定→
1383
+ * rollout fallback へ進む)。cursor が進まない・既出 cursor へ戻る(循環)・空ページ・上限ページ数・
1384
+ * `until` 成立で打ち切り、無限ループしない。壊れた cursor のページは取り込んだうえで打ち切る
1385
+ * (重複は呼び出し側が id で排除する)。
1386
+ */
1387
+ async function pageThreadHistory(connection, method, threadId, params, options, collect, until = () => false) {
1388
+ let cursor = null;
1389
+ const visited = new Set();
1390
+ for (let page = 1; page <= THREAD_HISTORY_MAX_PAGES; page += 1) {
1391
+ const timeoutMs = historyRequestTimeout(method, options.deadlineMs, options.timeoutMs);
1392
+ const response = objectRecord(await connection.request(method, {
1393
+ threadId,
1394
+ limit: options.limit,
1395
+ sortDirection: options.sortDirection,
1396
+ ...params,
1397
+ ...(cursor === null ? {} : { cursor }),
1398
+ }, timeoutMs));
1399
+ const data = response?.["data"];
1400
+ if (!Array.isArray(data)) {
1401
+ throw new CodexHistoryShapeError(`Codex App Server ${method} response omitted data for thread ${threadId}`);
1402
+ }
1403
+ collect(data);
1404
+ const rawNext = response?.["nextCursor"];
1405
+ const next = typeof rawNext === "string" && rawNext.length > 0 ? rawNext : null;
1406
+ if (!options.all || next === null || visited.has(next) || data.length === 0 || until())
1407
+ return;
1408
+ visited.add(next);
1409
+ cursor = next;
1410
+ }
1411
+ throw new CodexHistoryShapeError(`Codex App Server ${method} exceeded ${THREAD_HISTORY_MAX_PAGES} pages for thread ${threadId}`);
1412
+ }
1413
+ function historyRequestTimeout(method, deadlineMs, timeoutMs) {
1414
+ if (deadlineMs === undefined)
1415
+ return timeoutMs;
1416
+ const remaining = deadlineMs - Date.now();
1417
+ if (remaining <= 0)
1418
+ throw new Error(`Codex App Server request timed out: ${method}`);
1419
+ return Math.min(remaining, timeoutMs ?? remaining);
1420
+ }
1421
+ /** id 付き要素の重複(同じページを返し続ける壊れた cursor 等)を排除する。id 無しは常に採用。 */
1422
+ function rememberId(seen, id) {
1423
+ if (typeof id !== "string" || id.length === 0)
1424
+ return true;
1425
+ if (seen.has(id))
1426
+ return false;
1427
+ seen.add(id);
1428
+ return true;
1429
+ }
1430
+ /**
1431
+ * `thread/turns/list` で turn を読む。サポート下限の codex 0.144.5 から itemsView 付きで利用できる。
1432
+ * 用途は turn 状態の読み取り(desc, limit 1, notLoaded)に限る。履歴の hydration には使わない
1433
+ * (readThreadHistorySnapshot): `itemsView:"full"` は server 側で「旧 client が thread/items/list へ
1434
+ * 移行するまでの互換経路」とされ、legacy thread の turns/list は itemsView に関わらず
1435
+ * リクエスト毎に rollout 全体を再生する(server 実装)。
1436
+ */
1437
+ async function listThreadTurns(connection, threadId, options) {
1438
+ const turns = [];
1439
+ const seen = new Set();
1440
+ await pageThreadHistory(connection, "thread/turns/list", threadId, { itemsView: options.itemsView }, options, (data) => {
1441
+ for (const turn of data) {
1442
+ const record = objectRecord(turn);
1443
+ if (record !== null && rememberId(seen, record["id"]))
1444
+ turns.push(record);
1445
+ }
1446
+ });
1447
+ return turns;
1448
+ }
1449
+ /**
1450
+ * `thread/items/list`(paginated thread 専用。legacy thread は -32601 `not supported yet`)で
1451
+ * item を読む。応答の data は turn を跨いだ時系列順(asc)。0.153.4 は `{ turnId, item }` の
1452
+ * 封筒、サポート下限 0.144.5 の protocol は素の ThreadItem 配列なので両方を受け、どちらでもない
1453
+ * 形(非空ページで採用 0 件)は throw する(無言の空履歴で live 権威にしない)。
1454
+ * `until` が true を返した時点で続きのページを読まない。
1455
+ */
1456
+ async function listThreadItems(connection, threadId, options, until = () => false) {
1457
+ const items = [];
1458
+ const seen = new Set();
1459
+ let pages = 0;
1460
+ await pageThreadHistory(connection, "thread/items/list", threadId, {}, options, (data) => {
1461
+ pages += 1;
1462
+ let accepted = 0;
1463
+ for (const entry of data) {
1464
+ const record = objectRecord(entry);
1465
+ const item = objectRecord(record?.["item"]) ??
1466
+ (typeof record?.["type"] === "string" ? record : null);
1467
+ if (item === null)
1468
+ continue;
1469
+ accepted += 1;
1470
+ if (rememberId(seen, item["id"]))
1471
+ items.push(item);
1472
+ }
1473
+ if (data.length > 0 && accepted === 0) {
1474
+ throw new CodexHistoryShapeError(`Codex App Server thread/items/list returned items of unknown shape for thread ${threadId}`);
1475
+ }
1476
+ }, () => until(items, pages));
1477
+ return items;
1478
+ }
1479
+ /**
1480
+ * thread/read includeTurns:true 応答の `thread.turns`。protocol 上は常に配列(他の応答では空配列)
1481
+ * なので、欠落は応答形の違いとして throw する(無言の空履歴で live 権威にしない)。
1482
+ */
1483
+ function turnsOfThreadResponse(response) {
1484
+ const turns = objectRecord(objectRecord(response)?.["thread"])?.["turns"];
1485
+ if (!Array.isArray(turns)) {
1486
+ throw new CodexHistoryShapeError("Codex App Server thread/read includeTurns:true response omitted thread.turns");
1487
+ }
1488
+ return turns
1489
+ .map(objectRecord)
1490
+ .filter((turn) => turn !== null);
1491
+ }
1492
+ /**
1493
+ * 購読直後の履歴スナップショット(Hub の rollout backfill と live 通知の境界に使う item 列と、
1494
+ * 別 client が開始した実行中 turn)を、非推奨でない API だけで読む。
1495
+ * - paginated(codex 0.153.4+ の新規 thread): server の案内どおり turn 状態は `thread/turns/list`
1496
+ * (desc, limit 1, notLoaded)、item は `thread/items/list`(asc)で読む。turn 前は最初の
1497
+ * `thread/turns/list` が `list_turns is not supported yet` で失敗し、呼び出し側が未 materialize
1498
+ * 判定へ進む。item 列は全履歴 hydration(server の paginated_thread_full_turns)と同じ
1499
+ * (0.153.4 実測)。
1500
+ * - legacy(rollout 保存の旧 thread): `thread/items/list` は未対応(-32601)。全履歴 hydration
1501
+ * `thread/read includeTurns:true` は legacy では非推奨でなく(server の通知は paginated 限定)、
1502
+ * rollout を 1 回再生するだけで済む。`thread/turns/list itemsView:full` はページ毎に rollout
1503
+ * 全体を再生する(server 実装)ため使わない。
1504
+ */
1505
+ async function readThreadHistorySnapshot(connection, threadId, historyMode, deadlineMs) {
1506
+ if (historyMode === "paginated") {
1507
+ const latest = await listThreadTurns(connection, threadId, {
1508
+ limit: 1,
1509
+ sortDirection: "desc",
1510
+ itemsView: "notLoaded",
1511
+ all: false,
1512
+ deadlineMs,
1513
+ });
1514
+ const items = await listThreadItems(connection, threadId, {
1515
+ limit: THREAD_HISTORY_PAGE_LIMIT,
1516
+ sortDirection: "asc",
1517
+ all: true,
1518
+ deadlineMs,
1519
+ });
1520
+ return { items, activeTurnId: activeTurnIdOfTurns(latest) };
1521
+ }
1522
+ const turns = turnsOfThreadResponse(await connection.request("thread/read", { threadId, includeTurns: true }, historyRequestTimeout("thread/read", deadlineMs)));
1523
+ return { items: itemsOfTurns(turns), activeTurnId: activeTurnIdOfTurns(turns) };
1524
+ }
1525
+ /**
1526
+ * 履歴スナップショットの読み取りを、ページング中の一時的な失敗(例: compaction で cursor の
1527
+ * anchor turn が消えた `invalid cursor`)に備えて 1 回だけやり直す。timeout(接続が刺さっている
1528
+ * 可能性)、未 materialize の既知文言(turn 前は何度読んでも同じ)、host 側で検出した応答形・
1529
+ * 上限の異常(恒久的)は再試行しない。
1530
+ */
1531
+ async function readThreadHistorySnapshotWithRetry(connection, threadId, historyMode) {
1532
+ const deadlineMs = Date.now() + THREAD_HISTORY_SNAPSHOT_TIMEOUT_MS;
1533
+ try {
1534
+ return await readThreadHistorySnapshot(connection, threadId, historyMode, deadlineMs);
1535
+ }
1536
+ catch (error) {
1537
+ if (!(error instanceof Error) ||
1538
+ error instanceof CodexHistoryShapeError ||
1539
+ error.message.startsWith("Codex App Server request timed out") ||
1540
+ isUnmaterializedThreadError(error, threadId)) {
1541
+ throw error;
1542
+ }
1543
+ return readThreadHistorySnapshot(connection, threadId, historyMode, deadlineMs);
1544
+ }
1545
+ }
1546
+ /** 命名用に最初の user prompt を保存済み履歴から読む(無ければ null)。 */
1547
+ async function readFirstUserPrompt(connection, threadId, historyMode) {
1548
+ if (historyMode === "legacy") {
1549
+ // legacy は rollout を 1 回で読む(readThreadHistorySnapshot と同じ理由)。全 turn を走査する。
1550
+ const response = await connection.request("thread/read", { threadId, includeTurns: true });
1551
+ return firstUserPromptOfItems(itemsOfTurns(turnsOfThreadResponse(response)));
1552
+ }
1553
+ // paginated は先頭から user prompt が見つかるまで(上限ページ数まで)読む。
1554
+ const items = await listThreadItems(connection, threadId, { limit: THREAD_HISTORY_PAGE_LIMIT, sortDirection: "asc", all: true }, (collected, pages) => pages >= THREAD_TITLE_PROMPT_MAX_PAGES || firstUserPromptOfItems(collected) !== null);
1555
+ return firstUserPromptOfItems(items);
1556
+ }
1557
+ function firstUserPromptOfItems(items) {
1558
+ return items
1559
+ .map(extractUserMessageText)
1560
+ .find((value) => value !== null && value.trim().length > 0) ?? null;
1561
+ }
1562
+ /** turn 一覧に含まれる item を時系列順に平坦化する。 */
1563
+ function itemsOfTurns(turns) {
1129
1564
  const result = [];
1130
- if (typeof response !== "object" || response === null)
1131
- return result;
1132
- const thread = response["thread"];
1133
- if (typeof thread !== "object" || thread === null)
1134
- return result;
1135
- const turns = thread["turns"];
1136
- if (!Array.isArray(turns))
1137
- return result;
1138
1565
  for (const turn of turns) {
1139
- if (typeof turn !== "object" || turn === null)
1140
- continue;
1141
1566
  const items = turn["items"];
1142
1567
  if (!Array.isArray(items))
1143
1568
  continue;
1144
1569
  for (const item of items) {
1145
- if (typeof item !== "object" || item === null || Array.isArray(item))
1146
- continue;
1147
- result.push(item);
1570
+ const record = objectRecord(item);
1571
+ if (record !== null)
1572
+ result.push(record);
1148
1573
  }
1149
1574
  }
1150
1575
  return result;
1151
1576
  }
1152
- /** thread/resume が返す turn 一覧から、別 client が開始した実行中 turn を復元する。 */
1153
- function extractActiveTurnId(response) {
1154
- if (typeof response !== "object" || response === null)
1155
- return null;
1156
- const thread = response["thread"];
1157
- if (typeof thread !== "object" || thread === null)
1158
- return null;
1159
- const turns = thread["turns"];
1160
- if (!Array.isArray(turns))
1161
- return null;
1577
+ /** turn 一覧から、別 client が開始した実行中 turn を復元する(末尾から最初の inProgress)。 */
1578
+ function activeTurnIdOfTurns(turns) {
1162
1579
  for (let index = turns.length - 1; index >= 0; index -= 1) {
1163
1580
  const turn = turns[index];
1164
- if (typeof turn !== "object" || turn === null || Array.isArray(turn))
1165
- continue;
1166
- const record = turn;
1167
- if (record["status"] !== "inProgress")
1581
+ if (turn === undefined || turn["status"] !== "inProgress")
1168
1582
  continue;
1169
- const id = record["id"];
1583
+ const id = turn["id"];
1170
1584
  if (typeof id === "string" && id.length > 0)
1171
1585
  return id;
1172
1586
  }
@@ -1179,6 +1593,11 @@ function defaultLaunch(executable, args, env) {
1179
1593
  });
1180
1594
  child.unref();
1181
1595
  }
1596
+ /** thread/start / thread/resume 応答の `thread.model`。無ければ null(旧 App Server)。 */
1597
+ function threadModel(response) {
1598
+ const thread = objectRecord(objectRecord(response)?.["thread"]);
1599
+ return stringValue(thread?.["model"]);
1600
+ }
1182
1601
  function extractThreadId(response) {
1183
1602
  if (typeof response !== "object" || response === null)
1184
1603
  return null;