tailii-host 0.13.0 → 0.15.0
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.
- package/dist/backend/tmux.d.ts +40 -0
- package/dist/backend/tmux.js +147 -7
- package/dist/backend/tmux.js.map +1 -1
- package/dist/chat/chatTailController.js +2 -1
- package/dist/chat/chatTailController.js.map +1 -1
- package/dist/chat/subagentTailer.js +20 -5
- package/dist/chat/subagentTailer.js.map +1 -1
- package/dist/chat/subagentTranscript.js +29 -0
- package/dist/chat/subagentTranscript.js.map +1 -1
- package/dist/chat/transcriptTailer.d.ts +13 -1
- package/dist/chat/transcriptTailer.js +113 -2
- package/dist/chat/transcriptTailer.js.map +1 -1
- package/dist/codex/codexAppServer.d.ts +9 -2
- package/dist/codex/codexAppServer.js +34 -5
- package/dist/codex/codexAppServer.js.map +1 -1
- package/dist/codex/codexAsyncQuestion.d.ts +53 -0
- package/dist/codex/codexAsyncQuestion.js +186 -0
- package/dist/codex/codexAsyncQuestion.js.map +1 -0
- package/dist/codex/codexMessagePhase.d.ts +16 -0
- package/dist/codex/codexMessagePhase.js +22 -0
- package/dist/codex/codexMessagePhase.js.map +1 -0
- package/dist/codex/codexNativeTurnController.d.ts +62 -0
- package/dist/codex/codexNativeTurnController.js +383 -8
- package/dist/codex/codexNativeTurnController.js.map +1 -1
- package/dist/codex/codexRolloutTailer.js +31 -5
- package/dist/codex/codexRolloutTailer.js.map +1 -1
- package/dist/codex/codexSessionStore.d.ts +16 -1
- package/dist/codex/codexSessionStore.js +90 -19
- package/dist/codex/codexSessionStore.js.map +1 -1
- package/dist/codex/codexSubagentTracker.js +16 -0
- package/dist/codex/codexSubagentTracker.js.map +1 -1
- package/dist/codex/codexSystemNotice.d.ts +7 -0
- package/dist/codex/codexSystemNotice.js +13 -0
- package/dist/codex/codexSystemNotice.js.map +1 -1
- package/dist/engine/engine.js +17 -3
- package/dist/engine/engine.js.map +1 -1
- package/dist/engine/handlers/conversation.js +32 -19
- package/dist/engine/handlers/conversation.js.map +1 -1
- package/dist/engine/handlers/mode.js +93 -3
- package/dist/engine/handlers/mode.js.map +1 -1
- package/dist/engine/handlers/session.js +3 -1
- package/dist/engine/handlers/session.js.map +1 -1
- package/dist/hub/hubDaemon.js +6 -2
- package/dist/hub/hubDaemon.js.map +1 -1
- package/dist/hub/hubProtocol.d.ts +7 -2
- package/dist/hub/hubProtocol.js +5 -2
- package/dist/hub/hubProtocol.js.map +1 -1
- package/dist/hub/panePreviewPump.d.ts +23 -0
- package/dist/hub/panePreviewPump.js +55 -1
- package/dist/hub/panePreviewPump.js.map +1 -1
- package/dist/hub/questionInjection.d.ts +7 -0
- package/dist/hub/questionInjection.js +18 -0
- package/dist/hub/questionInjection.js.map +1 -1
- package/dist/hub/sessionHub.d.ts +21 -1
- package/dist/hub/sessionHub.js +108 -4
- package/dist/hub/sessionHub.js.map +1 -1
- package/dist/protocol/decode.js +19 -1
- package/dist/protocol/decode.js.map +1 -1
- package/dist/protocol/encode.js +4 -0
- package/dist/protocol/encode.js.map +1 -1
- package/dist/protocol/messages.d.ts +50 -0
- package/dist/protocol/messages.js +17 -0
- package/dist/protocol/messages.js.map +1 -1
- package/dist/sessions/claudeSessionStore.d.ts +13 -1
- package/dist/sessions/claudeSessionStore.js +72 -7
- package/dist/sessions/claudeSessionStore.js.map +1 -1
- package/dist/sessions/listMemoFile.d.ts +24 -0
- package/dist/sessions/listMemoFile.js +133 -0
- package/dist/sessions/listMemoFile.js.map +1 -0
- package/dist/sessions/sessionSearch.js +5 -0
- package/dist/sessions/sessionSearch.js.map +1 -1
- package/dist/shared/clientBuild.d.ts +1 -1
- package/dist/shared/clientBuild.js +1 -1
- package/dist/shared/commandExpansion.d.ts +13 -0
- package/dist/shared/commandExpansion.js +31 -0
- package/dist/shared/commandExpansion.js.map +1 -0
- package/dist/shared/readFailures.d.ts +4 -0
- package/dist/shared/readFailures.js +20 -0
- package/dist/shared/readFailures.js.map +1 -0
- package/dist/shared/usageLimitWait.d.ts +28 -1
- package/dist/shared/usageLimitWait.js +197 -16
- package/dist/shared/usageLimitWait.js.map +1 -1
- package/package.json +3 -3
|
@@ -21,6 +21,8 @@ export interface CodexNativeTurnControllerOptions {
|
|
|
21
21
|
questions: QuestionPromptQuestion[];
|
|
22
22
|
}) => void;
|
|
23
23
|
onQuestionDismiss?: (session: string, id: string) => void;
|
|
24
|
+
/** 非同期質問の設問 id に入れる起動ごとの値(テストで固定する。既定は乱数)。 */
|
|
25
|
+
asyncPromptNonce?: string;
|
|
24
26
|
onChatItem?: (event: {
|
|
25
27
|
session: string;
|
|
26
28
|
itemId: string;
|
|
@@ -201,6 +203,15 @@ export declare class CodexNativeTurnController implements CodexTurnControllerRun
|
|
|
201
203
|
/** 開始中(opening)に close を要求された session。開き終わった時点で畳む。 */
|
|
202
204
|
private readonly closeRequestedWhileOpening;
|
|
203
205
|
private readonly pendingUserInput;
|
|
206
|
+
/** session → 未回答の非同期質問(codex-async-question)。 */
|
|
207
|
+
private readonly pendingAsyncQuestions;
|
|
208
|
+
/** 非同期質問の設問 id の通し番号(質問の並びが変わるたびに新しい id にする)。 */
|
|
209
|
+
private asyncPromptSeq;
|
|
210
|
+
/**
|
|
211
|
+
* 設問 id に入れる起動ごとの値。通し番号は hub の再起動で 1 に戻るので、これが無いと再起動前に iOS が
|
|
212
|
+
* 出した回答の自動再送が、復元後の別の並びの設問 id と一致して受理されてしまう。
|
|
213
|
+
*/
|
|
214
|
+
private readonly asyncPromptNonce;
|
|
204
215
|
private rememberPlanRestoreSnapshot;
|
|
205
216
|
constructor(options: CodexNativeTurnControllerOptions);
|
|
206
217
|
subscribeSession(options: {
|
|
@@ -247,6 +258,42 @@ export declare class CodexNativeTurnController implements CodexTurnControllerRun
|
|
|
247
258
|
close(): void;
|
|
248
259
|
/** iOS の既存 QuestionPromptSheet 回答を native requestUserInput response へ戻す。 */
|
|
249
260
|
answerQuestion(id: string, answers: QuestionAnswer[]): boolean;
|
|
261
|
+
/**
|
|
262
|
+
* 非同期質問の回答(codex-async-question)。iOS の設問シートは提示中の全問の回答を求めるので、
|
|
263
|
+
* 回答した分を片付け、提示後に届いて答えていない分があれば出し直す。回答は封筒にまとめ、質問した
|
|
264
|
+
* 実行中の turn へ steer する(新しい turn は始めない: hub の送信キューを迂回して turn/start が競合
|
|
265
|
+
* しないように。turn が終わっていれば TUI と同じく締め切り扱い)。送信は待たない(hub の回答受付は
|
|
266
|
+
* 同期の真偽で返す)。届かなかったときは会話へ注記する。
|
|
267
|
+
*/
|
|
268
|
+
private answerAsyncQuestions;
|
|
269
|
+
/** 回答の封筒を、質問した実行中の turn へ steer する(turn が替わっていれば締め切り扱い)。 */
|
|
270
|
+
private sendAsyncAnswer;
|
|
271
|
+
/** item/completed の agentMessage が非同期質問なら未回答に積む(`present` で iOS へ出す)。 */
|
|
272
|
+
private ingestAsyncQuestions;
|
|
273
|
+
/**
|
|
274
|
+
* ユーザー入力が回答の封筒なら、指された質問を未回答から外す(他クライアント = TUI / デスクトップの
|
|
275
|
+
* 回答で Tailii の設問を閉じ、二重回答を防ぐ。TUI の `resolve_answers` と同じ照合。自分の回答の
|
|
276
|
+
* こだまは既に片付け済みなので何も起きない)。
|
|
277
|
+
*/
|
|
278
|
+
private resolveAsyncAnswersFromInput;
|
|
279
|
+
private asyncQuestionsFor;
|
|
280
|
+
/**
|
|
281
|
+
* 未回答の非同期質問を 1 枚の設問として iOS へ出す(出し直す)。**質問の並びが 1 件でも変われば
|
|
282
|
+
* 設問 id を変える**(追記・他クライアントの回答で外れた分): hub は設問 id が一致する回答しか
|
|
283
|
+
* 受理しないので、回答は必ず提示した並びに対するものになる(同じ id で中身を差し替えると、すれ違った
|
|
284
|
+
* 回答の位置がずれて別の質問へ届く・追記分が回答済み扱いで消える。iOS のシートも同じ id では
|
|
285
|
+
* 作り直されず下書きの件数とずれる)。答えかけの下書きは iOS が前の id から先頭の一致分を引き継ぐ。
|
|
286
|
+
* 答えないと進まない設問(requestUserInput)が出ている間は待つ: hub の未回答の設問は会話に 1 つで、
|
|
287
|
+
* 上書きすると答えないと進まない設問が消える。
|
|
288
|
+
*/
|
|
289
|
+
private presentAsyncQuestions;
|
|
290
|
+
/** turn の終わり / 接続を畳むときに未回答の非同期質問を締め切る(TUI と同じ: 回答はターン中だけ)。 */
|
|
291
|
+
private expireAsyncQuestions;
|
|
292
|
+
/**
|
|
293
|
+
* 開いた時点で実行中の turn にある未回答の非同期質問を復元する(hub 再起動・開き直しの後も答えられる
|
|
294
|
+
* ように。TUI の ThreadSnapshot 再生と同じ)。turn 所属が分からない item(旧 App Server)は対象外。
|
|
295
|
+
*/
|
|
296
|
+
private restoreAsyncQuestions;
|
|
250
297
|
/**
|
|
251
298
|
* `intent`: "operate"(turn 開始 / 目標操作)は、開いている途中で close 要求が入って畳まれた場合に開き直す
|
|
252
299
|
* (閉じた接続へ turn/start を投げない)。"subscribe" は畳まれた OpenThread をそのまま返す(購読者が
|
|
@@ -261,6 +308,21 @@ export declare class CodexNativeTurnController implements CodexTurnControllerRun
|
|
|
261
308
|
private handleServerRequest;
|
|
262
309
|
private resolvePendingQuestionsForSession;
|
|
263
310
|
}
|
|
311
|
+
/** 実行中の端末への入力(stdin)承認のツール名(iOS の承認カードはツール名をそのまま見出しに出す)。 */
|
|
312
|
+
export declare const CODEX_WRITE_STDIN_TOOL_LABEL = "\u7AEF\u672B\u3078\u306E\u5165\u529B";
|
|
313
|
+
/**
|
|
314
|
+
* stdin 承認(`kind: "writeStdin"`)の要約。App Server は `command` に
|
|
315
|
+
* `shlex_join(["write_stdin", "--session-id", <端末 id>, <入力>])` を入れる(codex-rs core の
|
|
316
|
+
* tools/approvals.rs → app-server bespoke_event_handling.rs)。読めれば端末 id と入力を、読めなければ
|
|
317
|
+
* 生の command を出す。見た目を偽装できる文字(制御文字・C1・書式文字=bidi 制御 / ゼロ幅・行区切り)は
|
|
318
|
+
* `\u{…}` で見せる(TUI の `Input: {input:?}` = Rust Debug 相当。承認カードで中身を取り違えさせない)。
|
|
319
|
+
*/
|
|
320
|
+
export declare function codexWriteStdinApprovalSummary(command: string | null, reason: string | null): string;
|
|
321
|
+
/**
|
|
322
|
+
* POSIX shell の単語分割(クォートと `\` だけ。展開はしない)。閉じていないクォートは null。
|
|
323
|
+
* `shlex_join` の出力(単語をシングル / ダブルクォートで包む)を元の単語列へ戻すのに使う。
|
|
324
|
+
*/
|
|
325
|
+
export declare function splitShellWords(text: string): string[] | null;
|
|
264
326
|
/** rollout の event_msg(user_message / agent_message) と同じ表示範囲へ写像する。 */
|
|
265
327
|
export declare function codexItemToChatOutput(item: Record<string, unknown>): ControlMessage | null;
|
|
266
328
|
export declare function chatContentKey(payload: ControlMessage): string | null;
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
// Codex App Server の長寿命 thread 接続、native approval、turn/start を Tailii へ結線する。
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
2
3
|
import * as net from "node:net";
|
|
3
|
-
import { codexGoalFromWire } from "./codexAppServer.js";
|
|
4
|
+
import { codexGoalFromWire, codexItemTurnId } from "./codexAppServer.js";
|
|
4
5
|
import { decodeControlMessage, encodeControlMessage, PROTOCOL_V1, PROTOCOL_V2, } from "../protocol.js";
|
|
6
|
+
import { isVisibleCodexMessagePhase } from "./codexMessagePhase.js";
|
|
5
7
|
import { codexPlanChatOutput } from "./codexPlanItem.js";
|
|
6
8
|
import { resolveSocketPath } from "../shared/socketPath.js";
|
|
7
9
|
import { sleep } from "../shared/sleep.js";
|
|
8
10
|
import { codexItemToolActivities, codexPlanUpdateActivity, toolActivityContentKey, toolActivityMessage, } from "./codexToolActivity.js";
|
|
9
11
|
import { CodexSubagentTracker } from "./codexSubagentTracker.js";
|
|
10
|
-
import { codexAppServerSystemNotice, codexCollaborationModeSteerNotice, codexDeprecationNoticeLogLine, codexGoalClearedNotice, codexGoalNotice, codexGoalNoticeKey, codexMcpItemErrorNotice, codexSystemNoticeContentKey, } from "./codexSystemNotice.js";
|
|
12
|
+
import { codexAppServerSystemNotice, codexCollaborationModeSteerNotice, codexDeprecationNoticeLogLine, codexGoalClearedNotice, codexGoalNotice, codexGoalNoticeKey, codexAsyncAnswerFailedNotice, codexMcpItemErrorNotice, codexSystemNoticeContentKey, } from "./codexSystemNotice.js";
|
|
13
|
+
import { CODEX_ASYNC_QUESTION_ID_PREFIX, codexAsyncQuestionItem, codexAsyncQuestionReplyDisplayText, codexAsyncQuestionReplyTargets, codexAsyncQuestionReplyText, } from "./codexAsyncQuestion.js";
|
|
11
14
|
const TITLE_GENERATION_MAX_ATTEMPTS = 3;
|
|
12
15
|
const TITLE_GENERATION_RETRY_BASE_MS = 250;
|
|
13
16
|
/** 記録済み deprecationNotice 文言の保持上限。 */
|
|
@@ -41,6 +44,13 @@ function codexThreadIdTimestampMs(threadId) {
|
|
|
41
44
|
const value = Number.parseInt(compact.slice(0, 12), 16);
|
|
42
45
|
return Number.isSafeInteger(value) && value > 0 ? value : null;
|
|
43
46
|
}
|
|
47
|
+
/** 非同期質問 1 件の照合キー(`itemId#index`)。 */
|
|
48
|
+
function asyncEntryKey(entry) {
|
|
49
|
+
return `${entry.itemId}#${entry.question.index}`;
|
|
50
|
+
}
|
|
51
|
+
function isAnsweredAsyncEntry(pending, entry) {
|
|
52
|
+
return pending.answeredKeys.has(entry.itemId) || pending.answeredKeys.has(asyncEntryKey(entry));
|
|
53
|
+
}
|
|
44
54
|
/**
|
|
45
55
|
* Tailii から開始した Codex turn を同じ App Server 接続で保持する。
|
|
46
56
|
* server-initiated approval は既存 per-session serve socket へ渡すため、Codex hook は不要。
|
|
@@ -72,6 +82,15 @@ export class CodexNativeTurnController {
|
|
|
72
82
|
/** 開始中(opening)に close を要求された session。開き終わった時点で畳む。 */
|
|
73
83
|
closeRequestedWhileOpening = new Set();
|
|
74
84
|
pendingUserInput = new Map();
|
|
85
|
+
/** session → 未回答の非同期質問(codex-async-question)。 */
|
|
86
|
+
pendingAsyncQuestions = new Map();
|
|
87
|
+
/** 非同期質問の設問 id の通し番号(質問の並びが変わるたびに新しい id にする)。 */
|
|
88
|
+
asyncPromptSeq = 0;
|
|
89
|
+
/**
|
|
90
|
+
* 設問 id に入れる起動ごとの値。通し番号は hub の再起動で 1 に戻るので、これが無いと再起動前に iOS が
|
|
91
|
+
* 出した回答の自動再送が、復元後の別の並びの設問 id と一致して受理されてしまう。
|
|
92
|
+
*/
|
|
93
|
+
asyncPromptNonce;
|
|
75
94
|
rememberPlanRestoreSnapshot(session, threadId, thread) {
|
|
76
95
|
const snapshot = thread.planRestoreSnapshot;
|
|
77
96
|
if (snapshot === undefined)
|
|
@@ -89,6 +108,7 @@ export class CodexNativeTurnController {
|
|
|
89
108
|
this.onTokenUsage = options.onTokenUsage ?? (() => { });
|
|
90
109
|
this.onQuestion = options.onQuestion ?? (() => { });
|
|
91
110
|
this.onQuestionDismiss = options.onQuestionDismiss ?? (() => { });
|
|
111
|
+
this.asyncPromptNonce = options.asyncPromptNonce ?? randomBytes(3).toString("hex");
|
|
92
112
|
this.onChatItem = options.onChatItem ?? (() => { });
|
|
93
113
|
this.onGoal = options.onGoal ?? (() => { });
|
|
94
114
|
this.onCollaborationMode = options.onCollaborationMode ?? (() => { });
|
|
@@ -349,6 +369,7 @@ export class CodexNativeTurnController {
|
|
|
349
369
|
this.open.delete(session);
|
|
350
370
|
opened.thread.close();
|
|
351
371
|
this.resolvePendingQuestionsForSession(session);
|
|
372
|
+
this.pendingAsyncQuestions.delete(session);
|
|
352
373
|
this.onProcessing(session, "done");
|
|
353
374
|
}
|
|
354
375
|
close() {
|
|
@@ -357,6 +378,8 @@ export class CodexNativeTurnController {
|
|
|
357
378
|
}
|
|
358
379
|
/** iOS の既存 QuestionPromptSheet 回答を native requestUserInput response へ戻す。 */
|
|
359
380
|
answerQuestion(id, answers) {
|
|
381
|
+
if (this.answerAsyncQuestions(id, answers))
|
|
382
|
+
return true;
|
|
360
383
|
const pending = this.pendingUserInput.get(id);
|
|
361
384
|
if (pending === undefined)
|
|
362
385
|
return false;
|
|
@@ -383,8 +406,214 @@ export class CodexNativeTurnController {
|
|
|
383
406
|
}
|
|
384
407
|
pending.resolve({ answers: wireAnswers });
|
|
385
408
|
this.onQuestionDismiss(pending.session, id);
|
|
409
|
+
// 止まる設問の間は出せなかった非同期質問を、ここで出し直す。
|
|
410
|
+
this.presentAsyncQuestions(pending.session);
|
|
386
411
|
return true;
|
|
387
412
|
}
|
|
413
|
+
/**
|
|
414
|
+
* 非同期質問の回答(codex-async-question)。iOS の設問シートは提示中の全問の回答を求めるので、
|
|
415
|
+
* 回答した分を片付け、提示後に届いて答えていない分があれば出し直す。回答は封筒にまとめ、質問した
|
|
416
|
+
* 実行中の turn へ steer する(新しい turn は始めない: hub の送信キューを迂回して turn/start が競合
|
|
417
|
+
* しないように。turn が終わっていれば TUI と同じく締め切り扱い)。送信は待たない(hub の回答受付は
|
|
418
|
+
* 同期の真偽で返す)。届かなかったときは会話へ注記する。
|
|
419
|
+
*/
|
|
420
|
+
answerAsyncQuestions(id, answers) {
|
|
421
|
+
let session = null;
|
|
422
|
+
for (const [candidate, pending] of this.pendingAsyncQuestions) {
|
|
423
|
+
if (pending.promptId === id) {
|
|
424
|
+
session = candidate;
|
|
425
|
+
break;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
if (session === null)
|
|
429
|
+
return false;
|
|
430
|
+
const pending = this.pendingAsyncQuestions.get(session);
|
|
431
|
+
const presented = pending.presentedEntries;
|
|
432
|
+
const replies = [];
|
|
433
|
+
for (const answer of answers) {
|
|
434
|
+
const entry = presented[answer.questionIndex];
|
|
435
|
+
if (entry === undefined)
|
|
436
|
+
continue;
|
|
437
|
+
const selected = answer.selectedOptionIndexes.flatMap((index) => {
|
|
438
|
+
const label = entry.question.options[index];
|
|
439
|
+
return label === undefined ? [] : [label];
|
|
440
|
+
});
|
|
441
|
+
const other = answer.otherText?.trim() ?? "";
|
|
442
|
+
const text = [...selected, ...(other.length > 0 ? [other] : [])].join("\n").trim();
|
|
443
|
+
if (text.length === 0)
|
|
444
|
+
continue;
|
|
445
|
+
replies.push({
|
|
446
|
+
itemId: entry.itemId, index: entry.question.index, title: entry.question.title, answer: text,
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
// 提示した分は(回答の有無にかかわらず)終わり。残りがあれば新しい id で出し直す。
|
|
450
|
+
const presentedKeys = new Set(presented.map(asyncEntryKey));
|
|
451
|
+
pending.entries = pending.entries.filter((entry) => !presentedKeys.has(asyncEntryKey(entry)));
|
|
452
|
+
pending.promptId = null;
|
|
453
|
+
pending.presentedEntries = [];
|
|
454
|
+
pending.presentedKey = null;
|
|
455
|
+
this.onQuestionDismiss(session, id);
|
|
456
|
+
this.presentAsyncQuestions(session);
|
|
457
|
+
if (replies.length > 0)
|
|
458
|
+
void this.sendAsyncAnswer(session, pending.threadId, replies);
|
|
459
|
+
return true;
|
|
460
|
+
}
|
|
461
|
+
/** 回答の封筒を、質問した実行中の turn へ steer する(turn が替わっていれば締め切り扱い)。 */
|
|
462
|
+
async sendAsyncAnswer(session, threadId, replies) {
|
|
463
|
+
const failedItemId = replies[0].itemId;
|
|
464
|
+
const fail = (kind, error) => {
|
|
465
|
+
this.log(`Codex 非同期質問の回答送信失敗(${kind}) session=${session}: ${String(error)}`);
|
|
466
|
+
const notice = codexAsyncAnswerFailedNotice(failedItemId, kind);
|
|
467
|
+
this.onChatItem({ session, itemId: notice.itemId, payload: notice.payload });
|
|
468
|
+
};
|
|
469
|
+
const opened = this.open.get(session);
|
|
470
|
+
const turnId = opened?.threadId === threadId ? opened.activeTurnId : null;
|
|
471
|
+
if (opened === undefined || turnId === null) {
|
|
472
|
+
fail("expired", "no active turn");
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
try {
|
|
476
|
+
await opened.thread.steerTurn(turnId, codexAsyncQuestionReplyText(replies), null);
|
|
477
|
+
}
|
|
478
|
+
catch (error) {
|
|
479
|
+
// 別の turn が active(質問した turn は終わった)/ steer 不可は締め切り。timeout・切断は届いたか不明。
|
|
480
|
+
fail(activeTurnIdFromMismatch(error) !== null || isDefinitiveSteerRejection(error) ? "expired" : "uncertain", error);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
/** item/completed の agentMessage が非同期質問なら未回答に積む(`present` で iOS へ出す)。 */
|
|
484
|
+
ingestAsyncQuestions(session, opened, item, present = true) {
|
|
485
|
+
const asked = codexAsyncQuestionItem(item);
|
|
486
|
+
if (asked === null)
|
|
487
|
+
return;
|
|
488
|
+
const pending = this.asyncQuestionsFor(session, opened);
|
|
489
|
+
if (pending.seenItemIds.has(asked.itemId))
|
|
490
|
+
return;
|
|
491
|
+
pending.seenItemIds.add(asked.itemId);
|
|
492
|
+
for (const question of asked.questions) {
|
|
493
|
+
const entry = { itemId: asked.itemId, question };
|
|
494
|
+
if (!isAnsweredAsyncEntry(pending, entry))
|
|
495
|
+
pending.entries.push(entry);
|
|
496
|
+
}
|
|
497
|
+
if (present)
|
|
498
|
+
this.presentAsyncQuestions(session);
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* ユーザー入力が回答の封筒なら、指された質問を未回答から外す(他クライアント = TUI / デスクトップの
|
|
502
|
+
* 回答で Tailii の設問を閉じ、二重回答を防ぐ。TUI の `resolve_answers` と同じ照合。自分の回答の
|
|
503
|
+
* こだまは既に片付け済みなので何も起きない)。
|
|
504
|
+
*/
|
|
505
|
+
resolveAsyncAnswersFromInput(session, opened, item, present = true) {
|
|
506
|
+
if (item["type"] !== "userMessage")
|
|
507
|
+
return;
|
|
508
|
+
const content = item["content"];
|
|
509
|
+
if (!Array.isArray(content))
|
|
510
|
+
return;
|
|
511
|
+
const text = content.flatMap((part) => {
|
|
512
|
+
const record = asRecord(part);
|
|
513
|
+
return record?.["type"] === "text" && typeof record["text"] === "string" ? [record["text"]] : [];
|
|
514
|
+
}).join("\n");
|
|
515
|
+
const targets = codexAsyncQuestionReplyTargets(text);
|
|
516
|
+
if (targets === null)
|
|
517
|
+
return;
|
|
518
|
+
const pending = this.asyncQuestionsFor(session, opened);
|
|
519
|
+
for (const target of targets) {
|
|
520
|
+
// 開き直しの再生で、後から出る質問を回答済みとして覚えておく(item id 全体の旧形式も)。
|
|
521
|
+
pending.answeredKeys.add(target.index === null ? target.itemId : `${target.itemId}#${target.index}`);
|
|
522
|
+
}
|
|
523
|
+
const before = pending.entries.length;
|
|
524
|
+
pending.entries = pending.entries.filter((entry) => !isAnsweredAsyncEntry(pending, entry));
|
|
525
|
+
if (pending.entries.length === before)
|
|
526
|
+
return;
|
|
527
|
+
// 並びが変わったので新しい id で出し直す(同じ id のまま詰めると、iOS の回答の位置がずれる)。
|
|
528
|
+
if (present)
|
|
529
|
+
this.presentAsyncQuestions(session);
|
|
530
|
+
}
|
|
531
|
+
asyncQuestionsFor(session, opened) {
|
|
532
|
+
let pending = this.pendingAsyncQuestions.get(session);
|
|
533
|
+
if (pending === undefined || pending.threadId !== opened.threadId) {
|
|
534
|
+
if (pending?.promptId != null)
|
|
535
|
+
this.onQuestionDismiss(session, pending.promptId);
|
|
536
|
+
pending = {
|
|
537
|
+
threadId: opened.threadId, entries: [], promptId: null, presentedEntries: [], presentedKey: null,
|
|
538
|
+
seenItemIds: new Set(), answeredKeys: new Set(),
|
|
539
|
+
};
|
|
540
|
+
this.pendingAsyncQuestions.set(session, pending);
|
|
541
|
+
}
|
|
542
|
+
return pending;
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* 未回答の非同期質問を 1 枚の設問として iOS へ出す(出し直す)。**質問の並びが 1 件でも変われば
|
|
546
|
+
* 設問 id を変える**(追記・他クライアントの回答で外れた分): hub は設問 id が一致する回答しか
|
|
547
|
+
* 受理しないので、回答は必ず提示した並びに対するものになる(同じ id で中身を差し替えると、すれ違った
|
|
548
|
+
* 回答の位置がずれて別の質問へ届く・追記分が回答済み扱いで消える。iOS のシートも同じ id では
|
|
549
|
+
* 作り直されず下書きの件数とずれる)。答えかけの下書きは iOS が前の id から先頭の一致分を引き継ぐ。
|
|
550
|
+
* 答えないと進まない設問(requestUserInput)が出ている間は待つ: hub の未回答の設問は会話に 1 つで、
|
|
551
|
+
* 上書きすると答えないと進まない設問が消える。
|
|
552
|
+
*/
|
|
553
|
+
presentAsyncQuestions(session) {
|
|
554
|
+
const pending = this.pendingAsyncQuestions.get(session);
|
|
555
|
+
if (pending === undefined)
|
|
556
|
+
return;
|
|
557
|
+
if ([...this.pendingUserInput.values()].some((input) => input.session === session))
|
|
558
|
+
return;
|
|
559
|
+
const previous = pending.promptId;
|
|
560
|
+
const first = pending.entries[0];
|
|
561
|
+
if (first === undefined) {
|
|
562
|
+
pending.promptId = null;
|
|
563
|
+
pending.presentedEntries = [];
|
|
564
|
+
pending.presentedKey = null;
|
|
565
|
+
if (previous !== null)
|
|
566
|
+
this.onQuestionDismiss(session, previous);
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
const key = pending.entries.map(asyncEntryKey).join("\u0000");
|
|
570
|
+
if (previous !== null && pending.presentedKey === key)
|
|
571
|
+
return;
|
|
572
|
+
this.asyncPromptSeq += 1;
|
|
573
|
+
const id = `${CODEX_ASYNC_QUESTION_ID_PREFIX}${pending.threadId}:${asyncEntryKey(first)}:` +
|
|
574
|
+
`${this.asyncPromptNonce}-${this.asyncPromptSeq}`;
|
|
575
|
+
// 置き換えでは前の id の dismiss を送らない: hub は未回答の設問を新しい id で上書きし、iOS は手元の
|
|
576
|
+
// 前の設問と見比べて下書きを引き継ぐ(先に dismiss が届くと比べる相手が消える)。
|
|
577
|
+
pending.promptId = id;
|
|
578
|
+
pending.presentedEntries = [...pending.entries];
|
|
579
|
+
pending.presentedKey = key;
|
|
580
|
+
const questions = pending.entries.map(({ question }) => ({
|
|
581
|
+
header: "Codex の質問",
|
|
582
|
+
question: question.title,
|
|
583
|
+
multiSelect: false,
|
|
584
|
+
options: question.options.map((label) => ({ label, description: "" })),
|
|
585
|
+
}));
|
|
586
|
+
this.onQuestion({ session, id, questions });
|
|
587
|
+
}
|
|
588
|
+
/** turn の終わり / 接続を畳むときに未回答の非同期質問を締め切る(TUI と同じ: 回答はターン中だけ)。 */
|
|
589
|
+
expireAsyncQuestions(session) {
|
|
590
|
+
const pending = this.pendingAsyncQuestions.get(session);
|
|
591
|
+
if (pending === undefined)
|
|
592
|
+
return;
|
|
593
|
+
pending.entries = [];
|
|
594
|
+
pending.presentedEntries = [];
|
|
595
|
+
pending.presentedKey = null;
|
|
596
|
+
const previous = pending.promptId;
|
|
597
|
+
pending.promptId = null;
|
|
598
|
+
if (previous !== null)
|
|
599
|
+
this.onQuestionDismiss(session, previous);
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* 開いた時点で実行中の turn にある未回答の非同期質問を復元する(hub 再起動・開き直しの後も答えられる
|
|
603
|
+
* ように。TUI の ThreadSnapshot 再生と同じ)。turn 所属が分からない item(旧 App Server)は対象外。
|
|
604
|
+
*/
|
|
605
|
+
restoreAsyncQuestions(session, opened, items) {
|
|
606
|
+
const turnId = opened.activeTurnId;
|
|
607
|
+
if (turnId === null)
|
|
608
|
+
return;
|
|
609
|
+
for (const item of items) {
|
|
610
|
+
if (codexItemTurnId(item) !== turnId)
|
|
611
|
+
continue;
|
|
612
|
+
this.ingestAsyncQuestions(session, opened, item, false);
|
|
613
|
+
this.resolveAsyncAnswersFromInput(session, opened, item, false);
|
|
614
|
+
}
|
|
615
|
+
this.presentAsyncQuestions(session);
|
|
616
|
+
}
|
|
388
617
|
/**
|
|
389
618
|
* `intent`: "operate"(turn 開始 / 目標操作)は、開いている途中で close 要求が入って畳まれた場合に開き直す
|
|
390
619
|
* (閉じた接続へ turn/start を投げない)。"subscribe" は畳まれた OpenThread をそのまま返す(購読者が
|
|
@@ -488,6 +717,8 @@ export class CodexNativeTurnController {
|
|
|
488
717
|
this.onProcessing(session, "active");
|
|
489
718
|
// 目標は rollout に現在値が無い(SQLite 保存)ため、開くたびに App Server から読む(失敗無視)。
|
|
490
719
|
// Hub の close 判定(hasActiveGoal)が open 直後から正しい値を返すよう、ここで待つ。
|
|
720
|
+
// 実行中 turn の未回答の非同期質問は、待ち(目標の読み取り)の間に届く live の質問より先に並べる。
|
|
721
|
+
this.restoreAsyncQuestions(session, opened, thread.initialItems ?? []);
|
|
491
722
|
await this.loadGoal(session, opened);
|
|
492
723
|
if (this.open.get(session) !== opened)
|
|
493
724
|
return opened;
|
|
@@ -594,6 +825,12 @@ export class CodexNativeTurnController {
|
|
|
594
825
|
const payload = codexItemToChatOutput(item);
|
|
595
826
|
if (payload !== null)
|
|
596
827
|
this.onChatItem({ session, itemId: id, payload });
|
|
828
|
+
// 非同期質問(codex-async-question)は本文を最終回答と同じ吹き出しで残しつつ、回答できる設問として出す。
|
|
829
|
+
// 子 thread(サブエージェント)は質問しない(ツールが root thread だけに登録される)。
|
|
830
|
+
if (current?.threadId === threadId && current.activeTurnId !== null) {
|
|
831
|
+
this.ingestAsyncQuestions(session, current, item);
|
|
832
|
+
this.resolveAsyncAnswersFromInput(session, current, item);
|
|
833
|
+
}
|
|
597
834
|
const mcpNotice = current?.threadId === threadId ? codexMcpItemErrorNotice(item) : null;
|
|
598
835
|
if (mcpNotice !== null) {
|
|
599
836
|
this.onChatItem({ session, itemId: mcpNotice.itemId, payload: mcpNotice.payload });
|
|
@@ -725,13 +962,18 @@ export class CodexNativeTurnController {
|
|
|
725
962
|
const reason = typeof params["reason"] === "string" ? params["reason"] : null;
|
|
726
963
|
const cwd = typeof params["cwd"] === "string" ? params["cwd"] : fallbackCwd;
|
|
727
964
|
const isCommand = request.method === "item/commandExecution/requestApproval";
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
965
|
+
// 0.158+ は実行中の端末への入力(stdin)も同じ request で承認を求める(`kind: "writeStdin"`)。
|
|
966
|
+
// 新しいコマンドの実行と見分けられるよう、ツール名と要約を分ける。
|
|
967
|
+
const writesStdin = isCommand && params["kind"] === "writeStdin";
|
|
968
|
+
const summary = writesStdin
|
|
969
|
+
? codexWriteStdinApprovalSummary(command, reason)
|
|
970
|
+
: isCommand
|
|
971
|
+
? (command ?? reason ?? "コマンドの実行を許可しますか?")
|
|
972
|
+
: fileChangeSummary(item, reason);
|
|
731
973
|
const decision = await this.approvalBroker({
|
|
732
974
|
id: `codex:${String(params["threadId"] ?? "thread")}:${String(request.id)}`,
|
|
733
975
|
session,
|
|
734
|
-
tool: isCommand ? "Bash" : "Edit",
|
|
976
|
+
tool: writesStdin ? CODEX_WRITE_STDIN_TOOL_LABEL : isCommand ? "Bash" : "Edit",
|
|
735
977
|
summary,
|
|
736
978
|
cwd,
|
|
737
979
|
});
|
|
@@ -761,6 +1003,13 @@ export class CodexNativeTurnController {
|
|
|
761
1003
|
})
|
|
762
1004
|
: [],
|
|
763
1005
|
}));
|
|
1006
|
+
// 提示中の非同期質問は hub で上書きされる。止まる設問が終わったら出し直す(未提示に戻す)。
|
|
1007
|
+
const asyncPending = this.pendingAsyncQuestions.get(session);
|
|
1008
|
+
if (asyncPending !== undefined) {
|
|
1009
|
+
asyncPending.promptId = null;
|
|
1010
|
+
asyncPending.presentedEntries = [];
|
|
1011
|
+
asyncPending.presentedKey = null;
|
|
1012
|
+
}
|
|
764
1013
|
this.onQuestion({ session, id, questions });
|
|
765
1014
|
return new Promise((resolve) => {
|
|
766
1015
|
this.pendingUserInput.set(id, {
|
|
@@ -784,7 +1033,131 @@ export class CodexNativeTurnController {
|
|
|
784
1033
|
pending.resolve({ answers: {} });
|
|
785
1034
|
this.onQuestionDismiss(session, id);
|
|
786
1035
|
}
|
|
1036
|
+
this.expireAsyncQuestions(session);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
/** 実行中の端末への入力(stdin)承認のツール名(iOS の承認カードはツール名をそのまま見出しに出す)。 */
|
|
1040
|
+
export const CODEX_WRITE_STDIN_TOOL_LABEL = "端末への入力";
|
|
1041
|
+
/** stdin 承認の要約に載せる入力の上限(文字)。超えた分は省略を明記する(承認カード・通知に巨大な本文を流さない)。 */
|
|
1042
|
+
const WRITE_STDIN_INPUT_DISPLAY_LIMIT = 2000;
|
|
1043
|
+
/**
|
|
1044
|
+
* stdin 承認(`kind: "writeStdin"`)の要約。App Server は `command` に
|
|
1045
|
+
* `shlex_join(["write_stdin", "--session-id", <端末 id>, <入力>])` を入れる(codex-rs core の
|
|
1046
|
+
* tools/approvals.rs → app-server bespoke_event_handling.rs)。読めれば端末 id と入力を、読めなければ
|
|
1047
|
+
* 生の command を出す。見た目を偽装できる文字(制御文字・C1・書式文字=bidi 制御 / ゼロ幅・行区切り)は
|
|
1048
|
+
* `\u{…}` で見せる(TUI の `Input: {input:?}` = Rust Debug 相当。承認カードで中身を取り違えさせない)。
|
|
1049
|
+
*/
|
|
1050
|
+
export function codexWriteStdinApprovalSummary(command, reason) {
|
|
1051
|
+
const words = command === null ? null : splitShellWords(command);
|
|
1052
|
+
const lines = [];
|
|
1053
|
+
if (words !== null && words[0] === "write_stdin" && words[1] === "--session-id" && words.length >= 4) {
|
|
1054
|
+
lines.push(`実行中の端末 ${escapeInvisible(words[2])} へ入力を送ります`);
|
|
1055
|
+
const input = words.slice(3).join(" ");
|
|
1056
|
+
const shown = [...input];
|
|
1057
|
+
const clipped = shown.length > WRITE_STDIN_INPUT_DISPLAY_LIMIT;
|
|
1058
|
+
const body = clipped ? shown.slice(0, WRITE_STDIN_INPUT_DISPLAY_LIMIT).join("") : input;
|
|
1059
|
+
lines.push(`入力: "${escapeInvisible(body, true)}"${clipped ? `…(残り ${shown.length - WRITE_STDIN_INPUT_DISPLAY_LIMIT} 文字を省略)` : ""}`);
|
|
1060
|
+
}
|
|
1061
|
+
else {
|
|
1062
|
+
lines.push("実行中の端末へ入力を送ります");
|
|
1063
|
+
if (command !== null)
|
|
1064
|
+
lines.push(escapeInvisible(command));
|
|
1065
|
+
}
|
|
1066
|
+
if (reason !== null && reason.trim().length > 0)
|
|
1067
|
+
lines.push(`理由: ${escapeInvisible(reason.trim())}`);
|
|
1068
|
+
return lines.join("\n");
|
|
1069
|
+
}
|
|
1070
|
+
/**
|
|
1071
|
+
* 見た目を偽装できる文字を `\u{…}` / `\n` 等で見せる。`quoted` は `"` と `\\` もエスケープする(引用符で
|
|
1072
|
+
* 囲んで見せる入力用)。対象: C0 / DEL / C1 制御文字、書式文字(\p{Cf}: bidi 制御・ゼロ幅・BOM 等)、
|
|
1073
|
+
* 行区切り U+2028 / U+2029。
|
|
1074
|
+
*/
|
|
1075
|
+
function escapeInvisible(text, quoted = false) {
|
|
1076
|
+
let result = "";
|
|
1077
|
+
for (const char of text) {
|
|
1078
|
+
const code = char.codePointAt(0);
|
|
1079
|
+
if (char === "\n")
|
|
1080
|
+
result += "\\n";
|
|
1081
|
+
else if (char === "\r")
|
|
1082
|
+
result += "\\r";
|
|
1083
|
+
else if (char === "\t")
|
|
1084
|
+
result += "\\t";
|
|
1085
|
+
else if (quoted && (char === "\"" || char === "\\"))
|
|
1086
|
+
result += `\\${char}`;
|
|
1087
|
+
else if (code < 0x20 || (code >= 0x7f && code <= 0x9f) || code === 0x2028 || code === 0x2029 || /\p{Cf}/u.test(char)) {
|
|
1088
|
+
result += `\\u{${code.toString(16)}}`;
|
|
1089
|
+
}
|
|
1090
|
+
else {
|
|
1091
|
+
result += char;
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
return result;
|
|
1095
|
+
}
|
|
1096
|
+
/**
|
|
1097
|
+
* POSIX shell の単語分割(クォートと `\` だけ。展開はしない)。閉じていないクォートは null。
|
|
1098
|
+
* `shlex_join` の出力(単語をシングル / ダブルクォートで包む)を元の単語列へ戻すのに使う。
|
|
1099
|
+
*/
|
|
1100
|
+
export function splitShellWords(text) {
|
|
1101
|
+
const words = [];
|
|
1102
|
+
let current = "";
|
|
1103
|
+
let inWord = false;
|
|
1104
|
+
let index = 0;
|
|
1105
|
+
while (index < text.length) {
|
|
1106
|
+
const char = text[index];
|
|
1107
|
+
if (char === " " || char === "\t" || char === "\n") {
|
|
1108
|
+
if (inWord)
|
|
1109
|
+
words.push(current);
|
|
1110
|
+
current = "";
|
|
1111
|
+
inWord = false;
|
|
1112
|
+
index += 1;
|
|
1113
|
+
continue;
|
|
1114
|
+
}
|
|
1115
|
+
inWord = true;
|
|
1116
|
+
if (char === "'") {
|
|
1117
|
+
const end = text.indexOf("'", index + 1);
|
|
1118
|
+
if (end < 0)
|
|
1119
|
+
return null;
|
|
1120
|
+
current += text.slice(index + 1, end);
|
|
1121
|
+
index = end + 1;
|
|
1122
|
+
continue;
|
|
1123
|
+
}
|
|
1124
|
+
if (char === "\"") {
|
|
1125
|
+
index += 1;
|
|
1126
|
+
let closed = false;
|
|
1127
|
+
while (index < text.length) {
|
|
1128
|
+
const inner = text[index];
|
|
1129
|
+
if (inner === "\"") {
|
|
1130
|
+
closed = true;
|
|
1131
|
+
index += 1;
|
|
1132
|
+
break;
|
|
1133
|
+
}
|
|
1134
|
+
if (inner === "\\" && index + 1 < text.length && "$`\"\\\n".includes(text[index + 1])) {
|
|
1135
|
+
if (text[index + 1] !== "\n")
|
|
1136
|
+
current += text[index + 1];
|
|
1137
|
+
index += 2;
|
|
1138
|
+
continue;
|
|
1139
|
+
}
|
|
1140
|
+
current += inner;
|
|
1141
|
+
index += 1;
|
|
1142
|
+
}
|
|
1143
|
+
if (!closed)
|
|
1144
|
+
return null;
|
|
1145
|
+
continue;
|
|
1146
|
+
}
|
|
1147
|
+
if (char === "\\") {
|
|
1148
|
+
if (index + 1 >= text.length)
|
|
1149
|
+
return null;
|
|
1150
|
+
if (text[index + 1] !== "\n")
|
|
1151
|
+
current += text[index + 1];
|
|
1152
|
+
index += 2;
|
|
1153
|
+
continue;
|
|
1154
|
+
}
|
|
1155
|
+
current += char;
|
|
1156
|
+
index += 1;
|
|
787
1157
|
}
|
|
1158
|
+
if (inWord)
|
|
1159
|
+
words.push(current);
|
|
1160
|
+
return words;
|
|
788
1161
|
}
|
|
789
1162
|
/** rollout の event_msg(user_message / agent_message) と同じ表示範囲へ写像する。 */
|
|
790
1163
|
export function codexItemToChatOutput(item) {
|
|
@@ -804,16 +1177,18 @@ export function codexItemToChatOutput(item) {
|
|
|
804
1177
|
}).join("\n");
|
|
805
1178
|
if (text.length === 0)
|
|
806
1179
|
return null;
|
|
1180
|
+
// 非同期質問への回答(封筒)は `> 質問\n\n回答` で見せる(rollout 経路と同じ写像)。
|
|
1181
|
+
const displayText = codexAsyncQuestionReplyDisplayText(text) ?? text;
|
|
807
1182
|
// iOS の楽観バブルと rollout の client_id に合わせ、添付サムネのアンカーも一致させる。
|
|
808
1183
|
const clientId = item["clientId"];
|
|
809
1184
|
const streamId = typeof clientId === "string" && clientId.length > 0
|
|
810
1185
|
? `codex-user-${clientId}` : `codex-item-${id}`;
|
|
811
1186
|
return { type: "chat_output", v: PROTOCOL_V1, streamId,
|
|
812
|
-
role: "user", text, eof: true };
|
|
1187
|
+
role: "user", text: displayText, eof: true };
|
|
813
1188
|
}
|
|
814
1189
|
if (type === "agentMessage") {
|
|
815
1190
|
const phase = item["phase"];
|
|
816
|
-
if (phase
|
|
1191
|
+
if (!isVisibleCodexMessagePhase(phase))
|
|
817
1192
|
return null;
|
|
818
1193
|
const text = item["text"];
|
|
819
1194
|
if (typeof text !== "string" || text.length === 0)
|