@code-yeongyu/senpi 2026.10.18-1 → 2026.10.19

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 (154) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/bundle/chunks/{anthropic-messages-3CFTFCKV.js → anthropic-messages-EMTBHJMM.js} +2 -2
  3. package/dist/bundle/chunks/{args-SJWMIH2H.js → args-LWR7VFKS.js} +1 -1
  4. package/dist/bundle/chunks/{chunk-IRKZ6FVC.js → chunk-35M5MVUZ.js} +149 -145
  5. package/dist/bundle/chunks/chunk-3DNYAO3P.js +2 -0
  6. package/dist/bundle/chunks/{chunk-T2BEB5SO.js → chunk-7QPFFAJT.js} +1 -1
  7. package/dist/bundle/chunks/{chunk-4RUVN7K3.js → chunk-BOCN744P.js} +1 -1
  8. package/dist/bundle/chunks/{chunk-DDHBG2HU.js → chunk-FM7NDZR5.js} +1 -1
  9. package/dist/bundle/chunks/{chunk-OMY4EC25.js → chunk-FSENE7JK.js} +2 -2
  10. package/dist/bundle/chunks/{chunk-Z2LCTXS2.js → chunk-G7LLAQXI.js} +3 -3
  11. package/dist/bundle/chunks/{chunk-NRHOGI4Z.js → chunk-IM6ZWYTW.js} +2 -2
  12. package/dist/bundle/chunks/{chunk-MLKYJIUK.js → chunk-KM4FMQII.js} +1 -1
  13. package/dist/bundle/chunks/{chunk-OOWML2KE.js → chunk-PMGEQYEP.js} +2 -2
  14. package/dist/bundle/chunks/chunk-QGJLZ6BI.js +2 -0
  15. package/dist/bundle/chunks/{chunk-6EXDMO4S.js → chunk-QJ6MUFXY.js} +1 -1
  16. package/dist/bundle/chunks/{chunk-FZTO2ICI.js → chunk-QRGAMVEE.js} +1 -1
  17. package/dist/bundle/chunks/{chunk-3QVP3ZVI.js → chunk-QXEWAKXA.js} +1 -1
  18. package/dist/bundle/chunks/{chunk-UUMKAA7S.js → chunk-RL5QMGQD.js} +9 -9
  19. package/dist/bundle/chunks/chunk-T36LJ6MW.js +3 -0
  20. package/dist/bundle/chunks/{chunk-GAHIHXVE.js → chunk-VEKNDMUA.js} +1 -1
  21. package/dist/bundle/chunks/{chunk-TFXUF2IL.js → chunk-VPV7ADF3.js} +1 -1
  22. package/dist/bundle/chunks/{chunk-RELAITQ7.js → chunk-X6TWPBI6.js} +1 -1
  23. package/dist/bundle/chunks/{chunk-PKDWFVE4.js → chunk-YUMRZIKJ.js} +6 -6
  24. package/dist/bundle/chunks/chunk-ZPSJQHDK.js +395 -0
  25. package/dist/bundle/chunks/{cli-main-JCSB54VT.js → cli-main-EABMHWFV.js} +1 -1
  26. package/dist/bundle/chunks/{help-fast-path-FVPGRSYA.js → help-fast-path-BYWJN6YX.js} +1 -1
  27. package/dist/bundle/chunks/{host-command-X2ZZFTME.js → host-command-5T34JLBP.js} +1 -1
  28. package/dist/bundle/chunks/{host-lifecycle-OKWTQFUJ.js → host-lifecycle-GSRIQP6Z.js} +1 -1
  29. package/dist/bundle/chunks/host-lifecycle.js +1 -1
  30. package/dist/bundle/chunks/{host-session-command-HKJKOAVS.js → host-session-command-LF6JODPX.js} +1 -1
  31. package/dist/bundle/chunks/{interactive-mode-GJERAFGV.js → interactive-mode-GMWHBIUC.js} +1 -1
  32. package/dist/bundle/chunks/{list-tips-AWDAUHF2.js → list-tips-VSWED3PT.js} +1 -1
  33. package/dist/bundle/chunks/{multi-session-host-JWMGLKAG.js → multi-session-host-3J7STKGD.js} +4 -4
  34. package/dist/bundle/chunks/{openai-codex-responses-RLYFSLK6.js → openai-codex-responses-7YOBPZ2N.js} +1 -1
  35. package/dist/bundle/chunks/{openai-completions-OWNSXMTD.js → openai-completions-HSZPYXEA.js} +1 -1
  36. package/dist/bundle/chunks/{package-manager-cli-FSVS2ND2.js → package-manager-cli-LWQZSWKL.js} +1 -1
  37. package/dist/bundle/chunks/{rotation-stream-M6QV2MMB.js → rotation-stream-QRRWL73X.js} +1 -1
  38. package/dist/bundle/chunks/{rpc-mode-TPG3J3ZJ.js → rpc-mode-6LSWRKXR.js} +1 -1
  39. package/dist/bundle/chunks/{schedule-command-PAVLVMWL.js → schedule-command-7CXMW4WK.js} +1 -1
  40. package/dist/bundle/chunks/{session-control-endpoint-NSMRMKKK.js → session-control-endpoint-E265WAIQ.js} +1 -1
  41. package/dist/bundle/chunks/{session-picker-UV6IVWJY.js → session-picker-XRB3LP5W.js} +1 -1
  42. package/dist/bundle/chunks/session-worker.js +298 -293
  43. package/dist/bundle/cli.js +2 -2
  44. package/dist/bundle/index.js +1 -1
  45. package/dist/bundle/rpc-entry.js +1 -1
  46. package/dist/bundle/runtime-manifest.json +1 -1
  47. package/dist/core/agent-run-continuation.d.ts +24 -0
  48. package/dist/core/agent-run-continuation.js +57 -0
  49. package/dist/core/agent-session.d.ts +38 -1
  50. package/dist/core/agent-session.js +122 -13
  51. package/dist/core/extension-handler-stall-report.d.ts +11 -0
  52. package/dist/core/extension-handler-stall-report.js +26 -0
  53. package/dist/core/extensions/builtin/anthropic-subscription/account-command.js +1 -0
  54. package/dist/core/extensions/builtin/anthropic-subscription/affinity.d.ts +13 -1
  55. package/dist/core/extensions/builtin/anthropic-subscription/affinity.js +15 -4
  56. package/dist/core/extensions/builtin/anthropic-subscription/auth-lane.js +2 -0
  57. package/dist/core/extensions/builtin/anthropic-subscription/failover.d.ts +2 -0
  58. package/dist/core/extensions/builtin/anthropic-subscription/failover.js +3 -2
  59. package/dist/core/extensions/builtin/anthropic-subscription/guidance.d.ts +8 -0
  60. package/dist/core/extensions/builtin/anthropic-subscription/guidance.js +29 -0
  61. package/dist/core/extensions/builtin/anthropic-subscription/settings.d.ts +2 -0
  62. package/dist/core/extensions/builtin/anthropic-subscription/settings.js +4 -0
  63. package/dist/core/extensions/builtin/anthropic-subscription/stream-guidance.js +4 -2
  64. package/dist/core/extensions/handler-budget.d.ts +84 -0
  65. package/dist/core/extensions/handler-budget.js +136 -0
  66. package/dist/core/extensions/runner.d.ts +23 -0
  67. package/dist/core/extensions/runner.js +184 -99
  68. package/dist/core/footer-data-provider.js +2 -1
  69. package/dist/core/keybindings.d.ts +1 -1
  70. package/dist/core/keybindings.js +3 -2
  71. package/dist/core/pending-input-journal.d.ts +6 -1
  72. package/dist/core/pending-input-journal.js +90 -34
  73. package/dist/core/pending-input-public.d.ts +11 -0
  74. package/dist/core/pending-input-public.js +10 -0
  75. package/dist/core/pending-input-session-port.d.ts +15 -0
  76. package/dist/core/pending-input-session-port.js +93 -0
  77. package/dist/core/pending-input-session.d.ts +108 -0
  78. package/dist/core/pending-input-session.js +220 -0
  79. package/dist/core/pending-input-store.d.ts +32 -1
  80. package/dist/core/pending-input-store.js +74 -26
  81. package/dist/core/pending-input-types.d.ts +25 -4
  82. package/dist/core/pending-input.js +36 -18
  83. package/dist/core/session-entry-file.js +1 -0
  84. package/dist/core/session-log.js +1 -1
  85. package/dist/core/session-manager.d.ts +1 -0
  86. package/dist/core/session-manager.js +5 -1
  87. package/dist/core/session-move/bundle.d.ts +39 -0
  88. package/dist/core/session-move/bundle.js +108 -0
  89. package/dist/core/session-move/export.d.ts +18 -0
  90. package/dist/core/session-move/export.js +83 -0
  91. package/dist/core/session-move/finish.d.ts +24 -0
  92. package/dist/core/session-move/finish.js +81 -0
  93. package/dist/core/session-move/import.d.ts +32 -0
  94. package/dist/core/session-move/import.js +124 -0
  95. package/dist/core/session-move/move-ledger.d.ts +47 -0
  96. package/dist/core/session-move/move-ledger.js +151 -0
  97. package/dist/core/session-move/ownership.d.ts +61 -0
  98. package/dist/core/session-move/ownership.js +89 -0
  99. package/dist/core/session-move/requeue.d.ts +8 -0
  100. package/dist/core/session-move/requeue.js +18 -0
  101. package/dist/core/session-move/write-guard.d.ts +22 -0
  102. package/dist/core/session-move/write-guard.js +44 -0
  103. package/dist/core/settings-manager.d.ts +13 -0
  104. package/dist/core/settings-manager.js +17 -0
  105. package/dist/core/turn-deadline.d.ts +2 -0
  106. package/dist/core/turn-deadline.js +10 -3
  107. package/dist/modes/interactive/components/ask-user-async-widget.d.ts +7 -0
  108. package/dist/modes/interactive/components/ask-user-async-widget.js +20 -6
  109. package/dist/modes/rpc/connection-handler.js +3 -0
  110. package/dist/modes/rpc/effect-commands.js +3 -4
  111. package/dist/modes/rpc/host-observe-request.js +1 -0
  112. package/dist/modes/rpc/rpc-client.d.ts +10 -0
  113. package/dist/modes/rpc/rpc-client.js +11 -0
  114. package/dist/modes/rpc/rpc-input-validation.js +8 -0
  115. package/dist/modes/rpc/rpc-types.d.ts +25 -1
  116. package/dist/modes/rpc/session-command-activity.js +1 -0
  117. package/dist/modes/rpc/session-control-actions-data.js +17 -8
  118. package/dist/modes/rpc/session-held.js +1 -0
  119. package/dist/modes/rpc/session-release.js +16 -1
  120. package/dist/modes/rpc/tool-child/admission.d.ts +42 -0
  121. package/dist/modes/rpc/tool-child/admission.js +81 -0
  122. package/dist/modes/rpc/tool-child/admission.test.d.ts +2 -0
  123. package/dist/modes/rpc/tool-child/admission.test.js +166 -0
  124. package/dist/modes/rpc/tool-child/codec.d.ts +21 -0
  125. package/dist/modes/rpc/tool-child/codec.js +137 -0
  126. package/dist/modes/rpc/tool-child/codec.test.d.ts +2 -0
  127. package/dist/modes/rpc/tool-child/codec.test.js +234 -0
  128. package/dist/modes/rpc/tool-child/context.d.ts +42 -0
  129. package/dist/modes/rpc/tool-child/context.js +23 -0
  130. package/dist/modes/rpc/tool-child/flow-control.d.ts +24 -0
  131. package/dist/modes/rpc/tool-child/flow-control.js +80 -0
  132. package/dist/modes/rpc/tool-child/payload-ref-paths.d.ts +9 -0
  133. package/dist/modes/rpc/tool-child/payload-ref-paths.js +45 -0
  134. package/dist/modes/rpc/tool-child/payload-ref.d.ts +58 -0
  135. package/dist/modes/rpc/tool-child/payload-ref.js +166 -0
  136. package/dist/modes/rpc/tool-child/payload-ref.test.d.ts +2 -0
  137. package/dist/modes/rpc/tool-child/payload-ref.test.js +254 -0
  138. package/dist/modes/rpc/tool-child/payload-wire.d.ts +13 -0
  139. package/dist/modes/rpc/tool-child/payload-wire.js +22 -0
  140. package/dist/modes/rpc/tool-child/protocol-guard.d.ts +9 -0
  141. package/dist/modes/rpc/tool-child/protocol-guard.js +128 -0
  142. package/dist/modes/rpc/tool-child/protocol.d.ts +192 -0
  143. package/dist/modes/rpc/tool-child/protocol.js +54 -0
  144. package/dist/utils/wsl.d.ts +2 -2
  145. package/dist/utils/wsl.js +4 -4
  146. package/docs/providers.md +2 -0
  147. package/docs/rpc.md +16 -2
  148. package/docs/settings.md +1 -0
  149. package/docs/terminal-setup.md +2 -0
  150. package/package.json +10 -6
  151. package/dist/bundle/chunks/chunk-COJK5FDH.js +0 -2
  152. package/dist/bundle/chunks/chunk-IDXW4FAO.js +0 -2
  153. package/dist/bundle/chunks/chunk-PFNBY43W.js +0 -2
  154. package/dist/bundle/chunks/chunk-QBD4LY7D.js +0 -395
@@ -0,0 +1,151 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import lockfile from "proper-lockfile";
4
+ import { FILE_STORAGE_LOCK_RETRY_MAX_DELAY_MS, FILE_STORAGE_LOCK_RETRY_MIN_DELAY_MS, isLockError, } from "../lockfile-policy.js";
5
+ import { serializeByKey } from "../session-sidecar-store.js";
6
+ import { parseMoveId } from "./bundle.js";
7
+ export class MoveFencedError extends Error {
8
+ constructor(moveId, record) {
9
+ super(`Move ${moveId} can no longer be imported here: it was already answered (${record.state}).`);
10
+ this.code = "move_fenced";
11
+ this.name = "MoveFencedError";
12
+ this.record = record;
13
+ }
14
+ }
15
+ const LOCK_OPTIONS = { realpath: false, stale: 10_000, update: 2_000, retries: 0 };
16
+ const LOCK_WAIT_MS = 15_000;
17
+ const DECLINE_REASONS = [
18
+ "no_record",
19
+ "expired",
20
+ "cancelled",
21
+ "chunk_without_ready",
22
+ "repository",
23
+ ];
24
+ const FAIL_REASONS = ["bundle_invalid", "import_failed"];
25
+ function parseRecord(value) {
26
+ if (typeof value !== "object" || value === null)
27
+ return undefined;
28
+ const { state, reason } = value;
29
+ if (state === "receiving" || state === "accepted")
30
+ return { state };
31
+ const decline = DECLINE_REASONS.find((known) => known === reason);
32
+ if (state === "declined" && decline !== undefined)
33
+ return { state, reason: decline };
34
+ const fail = FAIL_REASONS.find((known) => known === reason);
35
+ if (state === "failed" && fail !== undefined)
36
+ return { state, reason: fail };
37
+ return undefined;
38
+ }
39
+ export class MoveLedger {
40
+ constructor(dir) {
41
+ this.dir = dir;
42
+ }
43
+ /** Starts receiving a move; refused once the move was answered with anything but `receiving`. */
44
+ beginReceive(moveId) {
45
+ return this.locked(moveId, () => {
46
+ const record = this.read(moveId);
47
+ if (record?.state === "accepted" || record?.state === "receiving")
48
+ return;
49
+ if (record !== undefined)
50
+ throw new MoveFencedError(moveId, record);
51
+ this.write(moveId, { state: "receiving" });
52
+ });
53
+ }
54
+ /** The signed answer a sender gets; `movedIn` is whether the session file here already records the move. */
55
+ answerStatus(moveId, here) {
56
+ return this.locked(moveId, () => {
57
+ const record = this.read(moveId);
58
+ if (here.movedIn || record?.state === "accepted")
59
+ return { state: "accepted" };
60
+ if (record !== undefined)
61
+ return record;
62
+ const declined = { state: "declined", reason: "no_record" };
63
+ this.write(moveId, declined);
64
+ return declined;
65
+ });
66
+ }
67
+ /** Cancel, expiry or a refused chunk: only before the bundle was verified and receiving began, never after. */
68
+ decline(moveId, reason) {
69
+ return this.settle(moveId, { state: "declined", reason }, ["accepted", "receiving"]);
70
+ }
71
+ /** A verified import that could not finish; it may end a receiving move but never an accepted one. */
72
+ fail(moveId, reason) {
73
+ return this.settle(moveId, { state: "failed", reason }, ["accepted"]);
74
+ }
75
+ /** Runs the import's commit (the atomic rename) under the move's lock, then records it accepted. */
76
+ commit(moveId, rename) {
77
+ return this.locked(moveId, async () => {
78
+ const record = this.read(moveId);
79
+ if (record !== undefined && record.state !== "receiving" && record.state !== "accepted") {
80
+ throw new MoveFencedError(moveId, record);
81
+ }
82
+ const result = await rename();
83
+ this.write(moveId, { state: "accepted" });
84
+ return result;
85
+ });
86
+ }
87
+ settle(moveId, next, keeps) {
88
+ return this.locked(moveId, () => {
89
+ const record = this.read(moveId);
90
+ if (record !== undefined && keeps.includes(record.state))
91
+ return record;
92
+ this.write(moveId, next);
93
+ return next;
94
+ });
95
+ }
96
+ path(moveId) {
97
+ return join(this.dir, `${parseMoveId(moveId)}.json`);
98
+ }
99
+ read(moveId) {
100
+ let raw;
101
+ try {
102
+ raw = readFileSync(this.path(moveId), "utf8");
103
+ }
104
+ catch (error) {
105
+ if (error.code === "ENOENT")
106
+ return undefined;
107
+ throw error;
108
+ }
109
+ const record = parseRecord(JSON.parse(raw));
110
+ if (record === undefined)
111
+ throw new Error(`Move record ${this.path(moveId)} is not a move record`);
112
+ return record;
113
+ }
114
+ write(moveId, record) {
115
+ const file = this.path(moveId);
116
+ const temp = `${file}.${process.pid}.tmp`;
117
+ writeFileSync(temp, `${JSON.stringify({ ...record, at: new Date().toISOString() })}\n`);
118
+ renameSync(temp, file);
119
+ }
120
+ locked(moveId, fn) {
121
+ const file = this.path(moveId);
122
+ return serializeByKey(file, async () => {
123
+ mkdirSync(this.dir, { recursive: true });
124
+ const release = await acquire(file);
125
+ try {
126
+ return await fn();
127
+ }
128
+ finally {
129
+ await release().catch(() => undefined);
130
+ }
131
+ });
132
+ }
133
+ }
134
+ async function acquire(file) {
135
+ const startedAt = Date.now();
136
+ for (let attempt = 0;; attempt += 1) {
137
+ try {
138
+ return await lockfile.lock(file, { ...LOCK_OPTIONS, lockfilePath: `${file}.lock` });
139
+ }
140
+ catch (error) {
141
+ if (!isLockError(error))
142
+ throw error;
143
+ const waited = Date.now() - startedAt;
144
+ if (waited >= LOCK_WAIT_MS)
145
+ throw new Error(`Move record ${file} stayed locked for ${waited}ms`, { cause: error });
146
+ const delay = Math.min(FILE_STORAGE_LOCK_RETRY_MIN_DELAY_MS * 2 ** attempt, FILE_STORAGE_LOCK_RETRY_MAX_DELAY_MS);
147
+ await new Promise((resolve) => setTimeout(resolve, delay));
148
+ }
149
+ }
150
+ }
151
+ //# sourceMappingURL=move-ledger.js.map
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Who may write a session file when sessions move between devices (sisyphuslabs/omo-desktop-app#3137,
3
+ * senpi#3630). The file itself records ownership as a sequence of transitions, folded in order:
4
+ * - writable (no ownership entry yet, a `takeover` release, `session_moved_in`, `session_reclaimed`);
5
+ * - `moving` after a `move` release: only that move may continue, with `session_moved` (it arrived),
6
+ * `session_reclaimed` (it was given back) or `session_moved_in` (an import of that same move);
7
+ * - `moved` after `session_moved`: nothing may reopen it. A new move id arriving by import replaces the file.
8
+ * A transition the current state does not allow is refused when written and ignored when read. Entries name the
9
+ * session they belong to (`session_id`); a copy or branch under a new id ignores the entries it inherited.
10
+ */
11
+ /** `customType` of the release entry; shared with `release_session` (`modes/rpc/session-release.ts`). */
12
+ export declare const SESSION_RELEASED = "session_released";
13
+ export declare const SESSION_MOVED_IN = "session_moved_in";
14
+ export declare const SESSION_MOVED = "session_moved";
15
+ export declare const SESSION_RECLAIMED = "session_reclaimed";
16
+ export type MoveId = string & {
17
+ readonly __brand: "MoveId";
18
+ };
19
+ export type RefusedOwnership = {
20
+ readonly writable: false;
21
+ readonly state: "moving";
22
+ readonly moveId: MoveId;
23
+ readonly target: string;
24
+ } | {
25
+ readonly writable: false;
26
+ readonly state: "moved";
27
+ readonly moveId: MoveId;
28
+ readonly target: string;
29
+ };
30
+ export type SessionOwnership = {
31
+ readonly writable: true;
32
+ } | RefusedOwnership;
33
+ export type OwnershipStep = {
34
+ readonly kind: "none";
35
+ } | {
36
+ readonly kind: "apply";
37
+ readonly next: SessionOwnership;
38
+ } | {
39
+ readonly kind: "refused";
40
+ };
41
+ interface EntryLike {
42
+ readonly type: string;
43
+ readonly customType?: string;
44
+ readonly data?: unknown;
45
+ readonly id?: unknown;
46
+ }
47
+ export declare function isOwnershipEntryType(customType: string | undefined): boolean;
48
+ /**
49
+ * The single transition rule. `sessionId` is the id of the session being written; an entry naming another session
50
+ * (inherited by a copy or branch) is not an ownership step for this one.
51
+ */
52
+ export declare function ownershipStep(current: SessionOwnership, entry: EntryLike, sessionId: string | undefined): OwnershipStep;
53
+ export declare function headerSessionId(entries: readonly EntryLike[]): string | undefined;
54
+ export declare function sessionOwnership(entries: readonly EntryLike[]): SessionOwnership;
55
+ export declare class SessionMovedAwayError extends Error {
56
+ readonly code = "session_moved_away";
57
+ readonly ownership: RefusedOwnership;
58
+ constructor(sessionFile: string, ownership: RefusedOwnership);
59
+ }
60
+ export {};
61
+ //# sourceMappingURL=ownership.d.ts.map
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Who may write a session file when sessions move between devices (sisyphuslabs/omo-desktop-app#3137,
3
+ * senpi#3630). The file itself records ownership as a sequence of transitions, folded in order:
4
+ * - writable (no ownership entry yet, a `takeover` release, `session_moved_in`, `session_reclaimed`);
5
+ * - `moving` after a `move` release: only that move may continue, with `session_moved` (it arrived),
6
+ * `session_reclaimed` (it was given back) or `session_moved_in` (an import of that same move);
7
+ * - `moved` after `session_moved`: nothing may reopen it. A new move id arriving by import replaces the file.
8
+ * A transition the current state does not allow is refused when written and ignored when read. Entries name the
9
+ * session they belong to (`session_id`); a copy or branch under a new id ignores the entries it inherited.
10
+ */
11
+ /** `customType` of the release entry; shared with `release_session` (`modes/rpc/session-release.ts`). */
12
+ export const SESSION_RELEASED = "session_released";
13
+ export const SESSION_MOVED_IN = "session_moved_in";
14
+ export const SESSION_MOVED = "session_moved";
15
+ export const SESSION_RECLAIMED = "session_reclaimed";
16
+ const WRITABLE = { writable: true };
17
+ const NONE = { kind: "none" };
18
+ const REFUSED = { kind: "refused" };
19
+ const OWNERSHIP_TYPES = new Set([
20
+ SESSION_RELEASED,
21
+ SESSION_MOVED_IN,
22
+ SESSION_MOVED,
23
+ SESSION_RECLAIMED,
24
+ ]);
25
+ export function isOwnershipEntryType(customType) {
26
+ return customType !== undefined && OWNERSHIP_TYPES.has(customType);
27
+ }
28
+ function field(data, key) {
29
+ if (typeof data !== "object" || data === null)
30
+ return undefined;
31
+ const value = data[key];
32
+ return typeof value === "string" ? value : undefined;
33
+ }
34
+ function requested(entry) {
35
+ const moveId = (field(entry.data, "move_id") ?? "");
36
+ const target = field(entry.data, "target") ?? "";
37
+ if (entry.customType === SESSION_MOVED)
38
+ return { writable: false, state: "moved", moveId, target };
39
+ if (entry.customType === SESSION_RELEASED && field(entry.data, "reason") === "move")
40
+ return { writable: false, state: "moving", moveId, target };
41
+ return WRITABLE;
42
+ }
43
+ /**
44
+ * The single transition rule. `sessionId` is the id of the session being written; an entry naming another session
45
+ * (inherited by a copy or branch) is not an ownership step for this one.
46
+ */
47
+ export function ownershipStep(current, entry, sessionId) {
48
+ if (entry.type !== "custom" || !isOwnershipEntryType(entry.customType))
49
+ return NONE;
50
+ const owner = field(entry.data, "session_id");
51
+ if (owner !== undefined && sessionId !== undefined && owner !== sessionId)
52
+ return NONE;
53
+ const next = requested(entry);
54
+ if (current.writable)
55
+ return { kind: "apply", next };
56
+ if (current.state === "moved")
57
+ return REFUSED;
58
+ const sameMove = field(entry.data, "move_id") === current.moveId;
59
+ const continuesMove = entry.customType === SESSION_MOVED ||
60
+ entry.customType === SESSION_RECLAIMED ||
61
+ entry.customType === SESSION_MOVED_IN;
62
+ return sameMove && continuesMove ? { kind: "apply", next } : REFUSED;
63
+ }
64
+ export function headerSessionId(entries) {
65
+ const header = entries.find((entry) => entry.type === "session");
66
+ return typeof header?.id === "string" ? header.id : undefined;
67
+ }
68
+ export function sessionOwnership(entries) {
69
+ const sessionId = headerSessionId(entries);
70
+ let verdict = WRITABLE;
71
+ for (const entry of entries) {
72
+ const step = ownershipStep(verdict, entry, sessionId);
73
+ if (step.kind === "apply")
74
+ verdict = step.next;
75
+ }
76
+ return verdict;
77
+ }
78
+ export class SessionMovedAwayError extends Error {
79
+ constructor(sessionFile, ownership) {
80
+ const where = ownership.target === "" ? "another device" : ownership.target;
81
+ super(ownership.state === "moving"
82
+ ? `This session is being moved to ${where}; it is read-only here. Wait for the move to finish, or make a copy to keep working here. (${sessionFile})`
83
+ : `This session was moved to ${where}; it is read-only here. Open it on ${where}, or make a copy here. (${sessionFile})`);
84
+ this.code = "session_moved_away";
85
+ this.name = "SessionMovedAwayError";
86
+ this.ownership = ownership;
87
+ }
88
+ }
89
+ //# sourceMappingURL=ownership.js.map
@@ -0,0 +1,8 @@
1
+ import type { MoveId } from "./ownership.ts";
2
+ /**
3
+ * Puts input a move took out of a session back into its pending-input journal, as follow-ups in their original
4
+ * order. Each gets the id `move:<moveId>:<index>`, so running this twice admits each input once: the second pass is
5
+ * answered from the journal (`replayed`). The session restores them to its queue when it is next opened.
6
+ */
7
+ export declare function requeueInput(sessionFile: string, moveId: MoveId, texts: readonly string[]): Promise<void>;
8
+ //# sourceMappingURL=requeue.d.ts.map
@@ -0,0 +1,18 @@
1
+ import { SessionPendingInputs } from "../pending-input-session.js";
2
+ import { SessionManager } from "../session-manager.js";
3
+ /**
4
+ * Puts input a move took out of a session back into its pending-input journal, as follow-ups in their original
5
+ * order. Each gets the id `move:<moveId>:<index>`, so running this twice admits each input once: the second pass is
6
+ * answered from the journal (`replayed`). The session restores them to its queue when it is next opened.
7
+ */
8
+ export async function requeueInput(sessionFile, moveId, texts) {
9
+ if (texts.length === 0)
10
+ return;
11
+ const sessionManager = SessionManager.open(sessionFile);
12
+ let next = 0;
13
+ const inputs = new SessionPendingInputs({ sessionManager, nextQueuedId: () => `move-${moveId}-${next++}` });
14
+ for (const [index, text] of texts.entries()) {
15
+ await inputs.admit({ clientMessageId: `move:${moveId}:${index}`, text, mode: "followUp" }, () => undefined);
16
+ }
17
+ }
18
+ //# sourceMappingURL=requeue.js.map
@@ -0,0 +1,22 @@
1
+ interface EntryLike {
2
+ readonly type: string;
3
+ readonly customType?: string;
4
+ readonly data?: unknown;
5
+ readonly id?: unknown;
6
+ }
7
+ /**
8
+ * The session-manager write funnel asks this before every persisted write. It folds only the entries appended since
9
+ * the last call (the funnel appends in place), and starts over when the entry array is replaced by a load or branch.
10
+ */
11
+ export declare class SessionWriteGuard {
12
+ private source;
13
+ private seen;
14
+ private sessionId;
15
+ private verdict;
16
+ assertWritable(entries: readonly EntryLike[], entry: EntryLike, sessionFile: string): void;
17
+ assertRewritable(entries: readonly EntryLike[], sessionFile: string): void;
18
+ isWritable(entries: readonly EntryLike[]): boolean;
19
+ private current;
20
+ }
21
+ export {};
22
+ //# sourceMappingURL=write-guard.d.ts.map
@@ -0,0 +1,44 @@
1
+ import { headerSessionId, ownershipStep, SessionMovedAwayError } from "./ownership.js";
2
+ /**
3
+ * The session-manager write funnel asks this before every persisted write. It folds only the entries appended since
4
+ * the last call (the funnel appends in place), and starts over when the entry array is replaced by a load or branch.
5
+ */
6
+ export class SessionWriteGuard {
7
+ constructor() {
8
+ this.seen = 0;
9
+ this.verdict = { writable: true };
10
+ }
11
+ assertWritable(entries, entry, sessionFile) {
12
+ const ownership = this.current(entries);
13
+ if (ownership.writable)
14
+ return;
15
+ if (ownershipStep(ownership, entry, this.sessionId).kind !== "apply")
16
+ throw new SessionMovedAwayError(sessionFile, ownership);
17
+ }
18
+ assertRewritable(entries, sessionFile) {
19
+ const ownership = this.current(entries);
20
+ if (!ownership.writable)
21
+ throw new SessionMovedAwayError(sessionFile, ownership);
22
+ }
23
+ isWritable(entries) {
24
+ return this.current(entries).writable;
25
+ }
26
+ current(entries) {
27
+ if (entries !== this.source || entries.length < this.seen) {
28
+ this.source = entries;
29
+ this.seen = 0;
30
+ this.sessionId = headerSessionId(entries);
31
+ this.verdict = { writable: true };
32
+ }
33
+ for (; this.seen < entries.length; this.seen += 1) {
34
+ const entry = entries[this.seen];
35
+ if (entry === undefined)
36
+ continue;
37
+ const step = ownershipStep(this.verdict, entry, this.sessionId);
38
+ if (step.kind === "apply")
39
+ this.verdict = step.next;
40
+ }
41
+ return this.verdict;
42
+ }
43
+ }
44
+ //# sourceMappingURL=write-guard.js.map
@@ -21,6 +21,13 @@ export declare const DEFAULT_SESSION_SHUTDOWN_HANDLER_WARN_MS = 2000;
21
21
  * hung handler still must not hold quit/reload/new/resume hostage.
22
22
  */
23
23
  export declare const DEFAULT_SESSION_SHUTDOWN_HANDLER_TIMEOUT_MS = 10000;
24
+ /** Warn threshold for one handler of an agent lifecycle event the session awaits before persisting (senpi#3650). */
25
+ export declare const DEFAULT_EXTENSION_EVENT_HANDLER_WARN_MS = 60000;
26
+ /**
27
+ * Hard cap for one such handler. Above the 15 min summarization budget a blocking compaction in a `message_end`
28
+ * handler may legitimately use; past it the host stops waiting so the turn is persisted and queued input delivered.
29
+ */
30
+ export declare const DEFAULT_EXTENSION_EVENT_HANDLER_TIMEOUT_MS = 1200000;
24
31
  export type TuiMode = RendererTuiMode;
25
32
  export type FullscreenExitOutput = "transcript" | "resume-hint";
26
33
  /**
@@ -133,6 +140,8 @@ export interface Settings {
133
140
  websocketConnectTimeoutMs?: number;
134
141
  sessionShutdownHandlerWarnMs?: number;
135
142
  sessionShutdownHandlerTimeoutMs?: number;
143
+ extensionEventHandlerWarnMs?: number;
144
+ extensionEventHandlerTimeoutMs?: number;
136
145
  tuiMode?: TuiMode;
137
146
  fullscreenExitOutput?: FullscreenExitOutput;
138
147
  fullscreenScrollbar?: ScrollViewScrollbar;
@@ -422,6 +431,10 @@ export declare class SettingsManager {
422
431
  */
423
432
  getSessionShutdownHandlerTimeoutMs(): number;
424
433
  setSessionShutdownHandlerTimeoutMs(timeoutMs: number): void;
434
+ /** How long one agent lifecycle event handler may run before the host reports it as stalled. 0 disables. */
435
+ getExtensionEventHandlerWarnMs(): number;
436
+ /** Hard cap on one agent lifecycle event handler; past it the session's event queue moves on. 0 disables. */
437
+ getExtensionEventHandlerTimeoutMs(): number;
425
438
  getProviderRetrySettings(): {
426
439
  timeoutMs?: number;
427
440
  maxRetries?: number;
@@ -36,6 +36,13 @@ export const DEFAULT_SESSION_SHUTDOWN_HANDLER_WARN_MS = 2_000;
36
36
  * hung handler still must not hold quit/reload/new/resume hostage.
37
37
  */
38
38
  export const DEFAULT_SESSION_SHUTDOWN_HANDLER_TIMEOUT_MS = 10_000;
39
+ /** Warn threshold for one handler of an agent lifecycle event the session awaits before persisting (senpi#3650). */
40
+ export const DEFAULT_EXTENSION_EVENT_HANDLER_WARN_MS = 60_000;
41
+ /**
42
+ * Hard cap for one such handler. Above the 15 min summarization budget a blocking compaction in a `message_end`
43
+ * handler may legitimately use; past it the host stops waiting so the turn is persisted and queued input delivered.
44
+ */
45
+ export const DEFAULT_EXTENSION_EVENT_HANDLER_TIMEOUT_MS = 1_200_000;
39
46
  const THINKING_LEVEL_VALUES = new Set([
40
47
  "off",
41
48
  "minimal",
@@ -1359,6 +1366,16 @@ export class SettingsManager {
1359
1366
  this.markModified("sessionShutdownHandlerTimeoutMs");
1360
1367
  this.save();
1361
1368
  }
1369
+ /** How long one agent lifecycle event handler may run before the host reports it as stalled. 0 disables. */
1370
+ getExtensionEventHandlerWarnMs() {
1371
+ return (parseTimeoutSetting(this.settings.extensionEventHandlerWarnMs, "extensionEventHandlerWarnMs") ??
1372
+ DEFAULT_EXTENSION_EVENT_HANDLER_WARN_MS);
1373
+ }
1374
+ /** Hard cap on one agent lifecycle event handler; past it the session's event queue moves on. 0 disables. */
1375
+ getExtensionEventHandlerTimeoutMs() {
1376
+ return (parseTimeoutSetting(this.settings.extensionEventHandlerTimeoutMs, "extensionEventHandlerTimeoutMs") ??
1377
+ DEFAULT_EXTENSION_EVENT_HANDLER_TIMEOUT_MS);
1378
+ }
1362
1379
  getProviderRetrySettings() {
1363
1380
  return {
1364
1381
  timeoutMs: this.settings.retry?.provider?.timeoutMs,
@@ -53,6 +53,8 @@ export interface DeadlineSessionOwner {
53
53
  _recordDeadlineStop(deadline: TurnDeadline): void;
54
54
  /** End the turn through the normal system abort (tools see the abort signal); resolves once the session is idle. */
55
55
  abortTurnForDeadline(): Promise<void>;
56
+ /** Stop waiting on extension handlers that still hold the turn; returns how many were released (senpi#3650). */
57
+ releaseHeldExtensionHandlers(): number;
56
58
  /** True while a provider retry is in flight; the engine is actively recovering the turn. */
57
59
  readonly isRetryInFlight: boolean;
58
60
  readonly isIdle: boolean;
@@ -153,18 +153,25 @@ export class AgentDeadlineTracker {
153
153
  }
154
154
  // The abort signal reaches every running tool (the bash tool kills its process tree on it), and the
155
155
  // normal settle path emits agent_idle once the run ends. A turn still running after the grace is held by
156
- // something no abort can end (an extension handler that never returns): it is reported, never faked idle.
156
+ // something no abort can end, usually an extension handler that never returns: the host stops waiting on
157
+ // those handlers and gives the settle one more grace. Only a turn still running after that is reported.
157
158
  let abortError;
159
+ const abortStartedAt = this.now();
158
160
  const aborting = this.session.abortTurnForDeadline().catch((error) => {
159
161
  abortError = error instanceof Error ? error.message : String(error);
160
162
  });
161
- const settled = await within(aborting, TURN_DEADLINE_GRACE_MS);
163
+ let settled = await within(aborting, TURN_DEADLINE_GRACE_MS);
164
+ if (!settled) {
165
+ // The abort path may already have released the waits (its own grace); either way the release gets a grace.
166
+ this.session.releaseHeldExtensionHandlers();
167
+ settled = await within(aborting, TURN_DEADLINE_GRACE_MS);
168
+ }
162
169
  if (abortError !== undefined)
163
170
  this.session._warnDeadline("turn_deadline_abort_failed", abortError);
164
171
  if (settled && abortError === undefined)
165
172
  return;
166
173
  if (!this.session.isIdle)
167
- this.session._warnDeadline("turn_deadline_unsettled", `turn still running ${TURN_DEADLINE_GRACE_MS} ms after the ${kind} deadline abort`);
174
+ this.session._warnDeadline("turn_deadline_unsettled", `turn still running ${this.now() - abortStartedAt} ms after the ${kind} deadline abort`);
168
175
  }
169
176
  }
170
177
  export function buildDeadlineStopNotice(deadline) {
@@ -25,6 +25,13 @@ export declare function renderQuestionLine(question: Question): string;
25
25
  export declare function renderOptionsLine(question: Question, remaining: number): string;
26
26
  /** Hint naming every way into the pending question; the shortcut segment follows the effective binding. */
27
27
  export declare function renderAnswerHint(): string;
28
+ /**
29
+ * The modifier that makes a drag select text while the app owns the mouse: Option on iTerm2 and
30
+ * Terminal.app, Option on macOS VS Code only with `terminal.integrated.macOptionClickForcesSelection`,
31
+ * Shift elsewhere (Windows Terminal, Ghostty, WezTerm, kitty, VS Code on Windows/Linux). Inside tmux
32
+ * the outer terminal decides and cannot be detected.
33
+ */
34
+ export declare function selectionBypassHint(termProgram?: string, platform?: NodeJS.Platform): string;
28
35
  export interface AskUserAsyncWidgetOptions {
29
36
  request: QuestionRequest;
30
37
  draft: QuestionDraft;
@@ -87,6 +87,21 @@ export function renderAnswerHint() {
87
87
  theme.fg("muted", "or just type your reply"),
88
88
  ].join(theme.fg("muted", " · "));
89
89
  }
90
+ /**
91
+ * The modifier that makes a drag select text while the app owns the mouse: Option on iTerm2 and
92
+ * Terminal.app, Option on macOS VS Code only with `terminal.integrated.macOptionClickForcesSelection`,
93
+ * Shift elsewhere (Windows Terminal, Ghostty, WezTerm, kitty, VS Code on Windows/Linux). Inside tmux
94
+ * the outer terminal decides and cannot be detected.
95
+ */
96
+ export function selectionBypassHint(termProgram = process.env.TERM_PROGRAM ?? "", platform = process.platform) {
97
+ if (termProgram === "iTerm.app" || termProgram === "Apple_Terminal")
98
+ return "option+drag";
99
+ if (termProgram === "vscode" && platform === "darwin")
100
+ return "option+drag (with terminal.integrated.macOptionClickForcesSelection)";
101
+ if (termProgram === "tmux")
102
+ return "shift+drag (option+drag on iTerm2/Terminal.app)";
103
+ return "shift+drag";
104
+ }
90
105
  export class AskUserAsyncWidget {
91
106
  constructor(options) {
92
107
  this.countdownLabel = "";
@@ -157,12 +172,11 @@ export class AskUserAsyncWidget {
157
172
  }
158
173
  if (this.pendingCount > 1)
159
174
  addAction(theme.fg("muted", `▸ +${this.pendingCount - 1} more · ${keyText("app.question.next")} next question`), "next");
160
- if (this.options.mouseCaptureActive) {
161
- const bypass = ["iTerm.app", "Apple_Terminal"].includes(process.env.TERM_PROGRAM ?? "")
162
- ? "option+drag"
163
- : "shift+drag";
164
- lines.push(...new Text(theme.fg("muted", `click an option · ${bypass} to select text`), 0, 0).render(width));
165
- }
175
+ const capture = this.options.mouseCaptureActive ? (this.options.tui?.mouseCaptureState() ?? "armed") : "off";
176
+ if (capture === "armed")
177
+ lines.push(...new Text(theme.fg("muted", `click an option · ${selectionBypassHint()} to select text`), 0, 0).render(width));
178
+ else if (capture === "yielded")
179
+ lines.push(...new Text(theme.fg("muted", "wheel scrolls · press any key to click options again"), 0, 0).render(width));
166
180
  lines.push(...new Text(renderAnswerHint(), 0, 0).render(width));
167
181
  return lines;
168
182
  }
@@ -836,6 +836,7 @@ export function createRpcConnectionHandler(runtimeHost, sink, options = {}) {
836
836
  case "clear_queue": {
837
837
  ClientAdmissions.clearQueuedFor(session);
838
838
  const cleared = session.clearQueue({ abortWillFollow: command.abortWillFollow ?? false });
839
+ await session.whenQueueClearRecorded();
839
840
  return success(id, "clear_queue", {
840
841
  steering: cleared.steering,
841
842
  followUp: cleared.followUp,
@@ -888,6 +889,8 @@ export function createRpcConnectionHandler(runtimeHost, sink, options = {}) {
888
889
  return success(id, "get_state", buildRpcSessionState(session, lastAbortSource));
889
890
  case "get_effect_status":
890
891
  return runGetEffectStatus(effectContext(), command);
892
+ case "get_pending_inputs":
893
+ return success(id, "get_pending_inputs", await session.getPendingInputs(command.knownClientMessageIds));
891
894
  // =================================================================
892
895
  // Model
893
896
  // =================================================================
@@ -150,10 +150,9 @@ export async function runQueuedInputChange(ctx, command) {
150
150
  });
151
151
  const clientMessageId = ctx.session.getQueuedInputs().find((input) => input.queuedId === queuedId)?.clientMessageId;
152
152
  const expected = command.expectedRevision === undefined ? undefined : { expectedRevision: command.expectedRevision };
153
- const status = command.type === "dequeue_message"
154
- ? ctx.session.dequeueQueuedInput(queuedId, expected)
155
- : ctx.session.editQueuedInput(queuedId, command.text, expected);
156
- // Read in the same synchronous step as the change: the revision the answer reports is the one it made or met.
153
+ const status = await ctx.session.changeQueuedInput(queuedId, command.type === "dequeue_message" ? undefined : command.text, { ...expected, ...(effectId === undefined ? {} : { effectId }) });
154
+ // Read right after the change, before anything else on this command: the revision the answer reports is the one
155
+ // it made or met.
157
156
  const current = ctx.session.getQueuedInputs().find((input) => input.queuedId === queuedId);
158
157
  // A durable input is restored from its admission record on reopen: that record must carry the edit too.
159
158
  if (status === "edited" && clientMessageId !== undefined && command.type === "edit_queued_message")
@@ -31,6 +31,7 @@ const OBSERVING_COMMANDS = new Set([
31
31
  "subscribe_sessions",
32
32
  "unsubscribe_sessions",
33
33
  "get_effect_status",
34
+ "get_pending_inputs",
34
35
  "get_subscription_limits",
35
36
  ]);
36
37
  export function isObservingRequest(line) {
@@ -86,6 +86,11 @@ export type RpcEffectStatusAnswer = Extract<RpcResponse, {
86
86
  command: "get_effect_status";
87
87
  success: true;
88
88
  }>["data"];
89
+ /** `get_pending_inputs`: pending inputs in durable custody, and an outcome for each known id. */
90
+ export type RpcPendingInputsAnswer = Extract<RpcResponse, {
91
+ command: "get_pending_inputs";
92
+ success: true;
93
+ }>["data"];
89
94
  export declare class RpcClientOpenInFlightError extends Error {
90
95
  readonly code: "open_session_in_flight";
91
96
  constructor();
@@ -251,6 +256,11 @@ export declare class RpcClient {
251
256
  getEffectStatus(effectId: string, options?: {
252
257
  readonly sessionPath?: string;
253
258
  }): Promise<RpcEffectStatusAnswer>;
259
+ /**
260
+ * The pending inputs in durable custody (queued, sending, failed) and an explicit outcome for each id in
261
+ * `knownClientMessageIds` (at most 64), consistent through `throughCursor`.
262
+ */
263
+ getPendingInputs(knownClientMessageIds?: readonly string[]): Promise<RpcPendingInputsAnswer>;
254
264
  /** Abort with host refusals and transport loss surfaced to machine callers. */
255
265
  abortStrict(): Promise<void>;
256
266
  abortCompaction(): Promise<void>;
@@ -480,6 +480,17 @@ export class RpcClient {
480
480
  };
481
481
  return this.getData(await this.send(command, options.sessionPath === undefined));
482
482
  }
483
+ /**
484
+ * The pending inputs in durable custody (queued, sending, failed) and an explicit outcome for each id in
485
+ * `knownClientMessageIds` (at most 64), consistent through `throughCursor`.
486
+ */
487
+ async getPendingInputs(knownClientMessageIds) {
488
+ const command = {
489
+ type: "get_pending_inputs",
490
+ ...(knownClientMessageIds === undefined ? {} : { knownClientMessageIds: [...knownClientMessageIds] }),
491
+ };
492
+ return this.getData(await this.send(command));
493
+ }
483
494
  /** Abort with host refusals and transport loss surfaced to machine callers. */
484
495
  async abortStrict() {
485
496
  this.getData(await this.send({ type: "abort" }));