zigmini 0.8.0-beta.6 → 0.9.0-beta.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 (62) hide show
  1. package/README.ko.md +90 -19
  2. package/README.md +91 -19
  3. package/assets/agent/gateway-request-rules.md +3 -0
  4. package/assets/agent/gateway-rules.md +6 -0
  5. package/assets/android/zigmini.apk +0 -0
  6. package/assets/android/zigmini.apk.json +3 -3
  7. package/assets/build.json +1 -1
  8. package/docs/manual.ko.md +69 -1
  9. package/docs/manual.md +67 -1
  10. package/host/omo-bridge/task-control.ts +4 -0
  11. package/host/omo-check/patches/omo-5.1.35-compaction-idle.patch +77 -0
  12. package/host/omo-check/patches/omo-5.1.35-compaction-intent.patch +212 -0
  13. package/host/omo-check/patches/omo-5.1.35-compaction-model.patch +299 -0
  14. package/host/omo-check/patches/omo-5.1.35-compaction-preservation.patch +1063 -0
  15. package/host/omo-check/patches/omo-5.1.35-compaction-refusal.patch +83 -0
  16. package/host/omo-check/patches/omo-5.1.35-gateway-port.manifest.json +169 -7
  17. package/host/omo-check/patches/omo-5.1.35-request-rules.patch +109 -0
  18. package/host/omo-check/patches/omo-5.1.35-turn-stats.patch +76 -0
  19. package/host/omo-check/patches/omo-5.1.44-compaction-port.patch +2630 -0
  20. package/host/omo-check/patches/omo-5.1.44-gateway-port.manifest.json +329 -0
  21. package/host/omo-check/patches/omo-5.1.44-provider-port.patch +440 -0
  22. package/host/omo-check/patches/omo-5.1.44-rpc-port.patch +778 -0
  23. package/package.json +4 -1
  24. package/scripts/secret-rules.ts +101 -0
  25. package/scripts/secret-scan.allow.json +155 -0
  26. package/scripts/secret-scan.rules.json +30 -0
  27. package/src/agent-request-rules.ts +82 -0
  28. package/src/cli/access.ts +31 -34
  29. package/src/cli/launchd.ts +30 -4
  30. package/src/cli/main.ts +3 -43
  31. package/src/cli/messages.ts +160 -0
  32. package/src/cli/pair.ts +32 -25
  33. package/src/cli/setup.ts +164 -75
  34. package/src/decisions.ts +48 -10
  35. package/src/engine/compat.json +3 -3
  36. package/src/hub.ts +183 -47
  37. package/src/main.ts +60 -4
  38. package/src/notification-history.ts +56 -0
  39. package/src/omo/client.ts +28 -0
  40. package/src/protocol.ts +32 -2
  41. package/src/reply-notifications.ts +144 -0
  42. package/src/runtime/paths.ts +2 -0
  43. package/src/security-allow.ts +126 -0
  44. package/src/security-check.ts +559 -0
  45. package/src/store.ts +236 -6
  46. package/src/turn-stats.ts +49 -0
  47. package/src/work-watch.ts +36 -10
  48. package/src/work.ts +45 -6
  49. package/web/DESIGN.md +39 -0
  50. package/web/app.js +289 -2
  51. package/web/decisions.d.ts +1 -1
  52. package/web/decisions.js +6 -0
  53. package/web/diagram.d.ts +28 -0
  54. package/web/diagram.js +228 -0
  55. package/web/index.html +29 -0
  56. package/web/notifications.d.ts +25 -0
  57. package/web/notifications.js +118 -0
  58. package/web/security-inbox.d.ts +35 -0
  59. package/web/security-inbox.js +237 -0
  60. package/web/style.css +43 -1
  61. package/web/turn-stats.d.ts +26 -0
  62. package/web/turn-stats.js +58 -0
@@ -0,0 +1,778 @@
1
+ --- a/@code-yeongyu/senpi/dist/core/client-message-identity.d.ts
2
+ +++ b/@code-yeongyu/senpi/dist/core/client-message-identity.d.ts
3
+ @@ -2,6 +2,7 @@
4
+ import { z } from "zod";
5
+ import type { InputSource } from "./extensions/types.ts";
6
+ export declare const clientMessageIdentitySchema: z.ZodObject<{
7
+ + clientInputId: z.ZodOptional<z.ZodString>;
8
+ clientMessageId: z.ZodOptional<z.ZodString>;
9
+ clientTurnId: z.ZodOptional<z.ZodString>;
10
+ }, z.core.$strip>;
11
+ @@ -17,4 +18,4 @@
12
+ /** Copy only correlation metadata, never the rest of a prompt's options. */
13
+ export declare function clientMessageIdentity(value?: ClientMessageIdentity): ClientMessageIdentity;
14
+ export declare function readClientMessageIdentity(value: unknown): ClientMessageIdentity;
15
+ -//# sourceMappingURL=client-message-identity.d.ts.map
16
+
17
+ +//# sourceMappingURL=client-message-identity.d.ts.map
18
+ --- a/@code-yeongyu/senpi/dist/core/client-message-identity.js
19
+ +++ b/@code-yeongyu/senpi/dist/core/client-message-identity.js
20
+ @@ -1,12 +1,15 @@
21
+ import { z } from "zod";
22
+ export const clientMessageIdentitySchema = z.object({
23
+ + clientInputId: z.string().min(1).max(128).optional(),
24
+ clientMessageId: z.string().min(1).max(256).optional(),
25
+ clientTurnId: z.string().min(1).max(256).optional(),
26
+ });
27
+ /** Copy only correlation metadata, never the rest of a prompt's options. */
28
+ export function clientMessageIdentity(value = {}) {
29
+ + const clientMessageId = value.clientMessageId ?? value.clientInputId;
30
+ return {
31
+ - ...(value.clientMessageId === undefined ? {} : { clientMessageId: value.clientMessageId }),
32
+ + ...(value.clientInputId === undefined ? {} : { clientInputId: value.clientInputId }),
33
+ + ...(clientMessageId === undefined ? {} : { clientMessageId }),
34
+ ...(value.clientTurnId === undefined ? {} : { clientTurnId: value.clientTurnId }),
35
+ };
36
+ }
37
+ @@ -14,4 +17,4 @@
38
+ const parsed = clientMessageIdentitySchema.safeParse(value);
39
+ return parsed.success ? clientMessageIdentity(parsed.data) : {};
40
+ }
41
+ -//# sourceMappingURL=client-message-identity.js.map
42
+
43
+ +//# sourceMappingURL=client-message-identity.js.map
44
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/ask-user/pending.js
45
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/ask-user/pending.js
46
+ @@ -39,7 +39,8 @@
47
+ }
48
+ export function createPendingQuestion(options) {
49
+ const createdAtMs = options.now();
50
+ - const hardCapMs = options.hardCapMs ?? DEFAULT_HARD_CAP_MS;
51
+ + // zgw: an unbounded idle timeout (required question) also has no hard cap.
52
+ + const hardCapMs = Number.isFinite(options.idleTimeoutMs) ? (options.hardCapMs ?? DEFAULT_HARD_CAP_MS) : Infinity;
53
+ const hardDeadlineAtMs = createdAtMs + hardCapMs;
54
+ const schedule = options.setTimeout ?? ((handler, delayMs) => setTimeout(handler, delayMs));
55
+ const unsched = options.clearTimeout ?? clearTimeout;
56
+ @@ -72,6 +73,8 @@
57
+ if (terminal !== undefined)
58
+ return;
59
+ const delayMs = deadlineAtMs - options.now();
60
+ + if (!Number.isFinite(delayMs))
61
+ + return;
62
+ if (delayMs <= 0) {
63
+ fireTimeout();
64
+ return;
65
+ @@ -131,4 +134,4 @@
66
+ },
67
+ };
68
+ }
69
+ -//# sourceMappingURL=pending.js.map
70
+
71
+ +//# sourceMappingURL=pending.js.map
72
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/ask-user/resume.js
73
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/ask-user/resume.js
74
+ @@ -5,6 +5,8 @@
75
+ import { DEFAULT_ASK_USER_TIMEOUT_MS, toCanonical, } from "./schema.js";
76
+ import { deliverAnswer, startQuestion } from "./tool.js";
77
+ export const ASK_USER_RESUMED_ENTRY = "ask-user:resumed";
78
+ +/** zgw bug 69: a required question that was live when the host stopped; its abort result does not end it. */
79
+ +export const ASK_USER_INTERRUPTED_ENTRY = "ask-user:interrupted";
80
+ function isRecord(value) {
81
+ return typeof value === "object" && value !== null && !Array.isArray(value);
82
+ }
83
+ @@ -19,6 +21,7 @@
84
+ const results = new Set();
85
+ const accepted = new Set();
86
+ const resumed = new Set();
87
+ + const interrupted = new Set();
88
+ const settled = new Set();
89
+ for (const entry of entries) {
90
+ if (entry.type === "message" && entry.message.role === "user") {
91
+ @@ -34,9 +37,14 @@
92
+ }
93
+ if (entry.type === "custom" && entry.customType === ASK_USER_RESUMED_ENTRY && isRecord(entry.data)) {
94
+ const toolCallId = entry.data.toolCallId;
95
+ - if (typeof toolCallId === "string")
96
+ + // zgw bug 32: a resumed question now records its own settlement, so only a settlement or an answer
97
+ + // frame ends it and it is resumed again after every restart. A marker without the flag predates that
98
+ + // record (its cancellation left none), so it still ends the question.
99
+ + if (typeof toolCallId === "string" && entry.data.settlesDurably !== true)
100
+ resumed.add(toolCallId);
101
+ }
102
+ + if (entry.type === "custom" && entry.customType === ASK_USER_INTERRUPTED_ENTRY && isRecord(entry.data) && typeof entry.data.toolCallId === "string")
103
+ + interrupted.add(entry.data.toolCallId);
104
+ if (entry.type === "message" && entry.message.role === "toolResult") {
105
+ results.add(entry.message.toolCallId);
106
+ const details = entry.message.details;
107
+ @@ -65,7 +73,7 @@
108
+ continue;
109
+ const call = { toolCallId: block.id, variant, args: block.arguments };
110
+ const request = requestFromCall(call, DEFAULT_ASK_USER_TIMEOUT_MS);
111
+ - if (results.has(block.id) && (request?.waitForAnswer === true || !accepted.has(block.id)))
112
+ + if (results.has(block.id) && !interrupted.has(block.id) && (request?.waitForAnswer === true || !accepted.has(block.id)))
113
+ continue;
114
+ if (resumed.has(block.id))
115
+ continue;
116
+ @@ -105,6 +113,18 @@
117
+ emitAskUserClosed(pi, lost.requestId, response);
118
+ deliverAnswer(pi, ctx, lost, response, dangling.variant);
119
+ }
120
+ +/**
121
+ + * zgw bug 69: called when the host stops (SIGTERM/SIGHUP) before the session is aborted. Each live required question
122
+ + * is recorded as interrupted and detached, so the shutdown abort neither settles it nor hides it from the next start.
123
+ + */
124
+ +export function interruptRequiredQuestionsForHostShutdown(sessionManager) {
125
+ + for (const entry of getPendingQuestions(sessionManager.getSessionId())) {
126
+ + if (!entry.request.waitForAnswer)
127
+ + continue;
128
+ + sessionManager.appendCustomEntry(ASK_USER_INTERRUPTED_ENTRY, { toolCallId: entry.request.requestId });
129
+ + entry.detach();
130
+ + }
131
+ +}
132
+ export async function resumeDanglingQuestion(pi, event, ctx) {
133
+ if (event.reason !== "resume" && event.reason !== "reload")
134
+ return;
135
+ @@ -114,7 +134,7 @@
136
+ if (pending.has(dangling.toolCallId))
137
+ continue;
138
+ pending.add(dangling.toolCallId);
139
+ - pi.appendEntry(ASK_USER_RESUMED_ENTRY, { toolCallId: dangling.toolCallId });
140
+ + pi.appendEntry(ASK_USER_RESUMED_ENTRY, { toolCallId: dangling.toolCallId, settlesDurably: true });
141
+ const request = requestFromCall(dangling, timeoutMs);
142
+ if (!request) {
143
+ settleUnrestorable(pi, ctx, dangling, timeoutMs);
144
+ @@ -126,4 +146,4 @@
145
+ });
146
+ }
147
+ }
148
+ -//# sourceMappingURL=resume.js.map
149
+
150
+ +//# sourceMappingURL=resume.js.map
151
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/ask-user/schema.js
152
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/ask-user/schema.js
153
+ @@ -99,8 +99,10 @@
154
+ requestId: options?.requestId ?? crypto.randomUUID(),
155
+ questions: args.questions.map((question, index) => parseQuestion(variant, question, index)),
156
+ waitForAnswer,
157
+ - timeoutMs: options?.timeoutMs ?? DEFAULT_ASK_USER_TIMEOUT_MS,
158
+ + // zgw: a waiting question is a required decision; it has no idle timeout or hard cap and
159
+ + // stays open until the user answers or cancels. Non-waiting questions keep the setting.
160
+ + timeoutMs: waitForAnswer ? Infinity : (options?.timeoutMs ?? DEFAULT_ASK_USER_TIMEOUT_MS),
161
+ ...(args.required === true ? { required: true } : {}),
162
+ };
163
+ }
164
+ -//# sourceMappingURL=schema.js.map
165
+
166
+ +//# sourceMappingURL=schema.js.map
167
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/ask-user/tool.js
168
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/ask-user/tool.js
169
+ @@ -78,7 +78,8 @@
170
+ let unregister = () => { };
171
+ const publish = (ownerPi, ownerCtx, response) => {
172
+ emitAskUserClosed(ownerPi, request.requestId, response);
173
+ - if (!request.waitForAnswer)
174
+ + // zgw bug 32: a resumed question has no tool result, so record every outcome (a silent cancellation too).
175
+ + if (!request.waitForAnswer || resuming)
176
+ ownerPi.appendEntry(ASK_USER_SETTLEMENT_ENTRY, {
177
+ requestId: request.requestId,
178
+ status: response.status,
179
+ @@ -175,7 +176,7 @@
180
+ timeout: request.timeoutMs,
181
+ signal: controller.signal,
182
+ deliver: request.waitForAnswer ? "tool-result" : "user-message",
183
+ - hardDeadlineAtMs: Date.now() + 7_200_000,
184
+ + hardDeadlineAtMs: Number.isFinite(request.timeoutMs) ? Date.now() + 7_200_000 : Infinity,
185
+ getDeadlineAtMs: () => pending.deadlineAtMs,
186
+ onProgress: (progress) => {
187
+ draft = { ...draft, ...progress, answers: progress.answers ?? draft.answers };
188
+ @@ -268,7 +269,7 @@
189
+ allowLazyActivation: false,
190
+ parameters: variant === "codex" ? CODEX_PARAMS : CLAUDE_PARAMS,
191
+ promptSnippet: "Ask a material question, explicitly choosing whether to wait or receive the answer later.",
192
+ - description: `${opener} Set ${flag} true to pause here until the user answers (the answer returns as this tool's result, or a timeout result after 30 idle minutes); set ${flag} false to keep working while the question stays open (the answer arrives later as a user message). Use it only when the answer materially changes the work; if it returns no answers, continue with best judgment instead of asking again, unless you set required true because the answer decides whether you take an action: then no answer means do not take it. Never use it for permission requests; ask those directly in your message. Unavailable to subagents.${variant === "claude" ? " Users will always be able to type a free-text answer or one comment covering everything; do not add an Other option." : ""} Call this tool directly, never from inside an eval cell (a cell that waits on the user would hold the kernel).`,
193
+ + description: `${opener} Set ${flag} true for a required confirmation: the work pauses here until the user answers or cancels, with no timeout (the answer returns as this tool's result); never proceed on your own judgment past a required question. Set ${flag} false when the work may continue without the answer (it arrives later as a user message, or a timeout after 30 idle minutes); if such a question returns no answers, continue with best judgment instead of asking again. Use it only when the answer materially changes the work. Never use it for permission requests; ask those directly in your message. Unavailable to subagents.${variant === "claude" ? " Users will always be able to type a free-text answer or one comment covering everything; do not add an Other option." : ""} Call this tool directly, never from inside an eval cell (a cell that waits on the user would hold the kernel).`,
194
+ prepareArguments(args) {
195
+ toCanonical(variant, args);
196
+ return args;
197
+ @@ -327,4 +328,4 @@
198
+ renderResult,
199
+ };
200
+ }
201
+ -//# sourceMappingURL=tool.js.map
202
+
203
+ +//# sourceMappingURL=tool.js.map
204
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/permission-system/events.d.ts
205
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/permission-system/events.d.ts
206
+ @@ -13,6 +13,9 @@
207
+ requestID: string;
208
+ sessionID: string;
209
+ reply: PermissionDecision;
210
+ + correlationId?: string;
211
+ + source?: "user" | "system" | "policy" | "cascade";
212
+ + choice?: string;
213
+ };
214
+ /**
215
+ * Event emitter interface for permission events.
216
+ @@ -30,7 +33,7 @@
217
+ * @param sessionID - The session ID associated with the request
218
+ * @param reply - The user's reply (once, always, or reject)
219
+ */
220
+ - emitReplied(requestID: string, sessionID: string, reply: PermissionDecision): void;
221
+ + emitReplied(requestID: string, sessionID: string, reply: PermissionDecision, evidence?: Pick<PermissionRepliedEvent, "correlationId" | "source" | "choice">): void;
222
+ }
223
+ /**
224
+ * Creates an event emitter that wraps pi.events for inter-extension communication.
225
+ @@ -64,4 +67,4 @@
226
+ */
227
+ clear(): void;
228
+ };
229
+ -//# sourceMappingURL=events.d.ts.map
230
+
231
+ +//# sourceMappingURL=events.d.ts.map
232
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/permission-system/events.js
233
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/permission-system/events.js
234
+ @@ -10,8 +10,8 @@
235
+ emitAsked: (request) => {
236
+ pi.events.emit("permission_asked", request);
237
+ },
238
+ - emitReplied: (requestID, sessionID, reply) => {
239
+ - pi.events.emit("permission_replied", { requestID, sessionID, reply });
240
+ + emitReplied: (requestID, sessionID, reply, evidence = {}) => {
241
+ + pi.events.emit("permission_replied", { requestID, sessionID, reply, ...evidence });
242
+ },
243
+ };
244
+ }
245
+ @@ -35,8 +35,8 @@
246
+ }
247
+ }
248
+ },
249
+ - emitReplied: (requestID, sessionID, reply) => {
250
+ - const event = { requestID, sessionID, reply };
251
+ + emitReplied: (requestID, sessionID, reply, evidence = {}) => {
252
+ + const event = { requestID, sessionID, reply, ...evidence };
253
+ for (const handler of repliedHandlers) {
254
+ try {
255
+ handler(event);
256
+ @@ -60,4 +60,4 @@
257
+ },
258
+ };
259
+ }
260
+ -//# sourceMappingURL=events.js.map
261
+
262
+ +//# sourceMappingURL=events.js.map
263
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/permission-system/index.js
264
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/permission-system/index.js
265
+ @@ -1,3 +1,4 @@
266
+ +import { randomUUID } from "node:crypto";
267
+ import { SettingsManager } from "../../../settings-manager.js";
268
+ import { extractPatchedPaths } from "../gpt-apply-patch/index.js";
269
+ import { decideAuto } from "./auto-policy.js";
270
+ @@ -258,6 +259,7 @@
271
+ const request = {
272
+ id: nextRequestID(),
273
+ sessionID,
274
+ + correlationId: randomUUID(),
275
+ permission: permissionRequest.permission,
276
+ patterns: permissionRequest.patterns,
277
+ always: permissionRequest.always,
278
+ @@ -345,4 +347,4 @@
279
+ }
280
+ });
281
+ }
282
+ -//# sourceMappingURL=index.js.map
283
+
284
+ +//# sourceMappingURL=index.js.map
285
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/permission-system/internal-tools.js
286
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/permission-system/internal-tools.js
287
+ @@ -2,9 +2,6 @@
288
+ export const INTERNAL_PERMISSION_TOOLS = new Set([
289
+ "todo",
290
+ "tool_search",
291
+ - "ask_user",
292
+ - "request_user_input",
293
+ - "ask_user_question",
294
+ "memory",
295
+ "monitor",
296
+ "create_goal",
297
+ @@ -14,4 +11,4 @@
298
+ "bash_resize",
299
+ "kill_bash",
300
+ ]);
301
+ -//# sourceMappingURL=internal-tools.js.map
302
+
303
+ +//# sourceMappingURL=internal-tools.js.map
304
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/permission-system/prompt.js
305
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/permission-system/prompt.js
306
+ @@ -14,17 +14,25 @@
307
+ : {};
308
+ if (signal !== undefined)
309
+ call.signal = signal;
310
+ - const choice = await ctx.ui.select(displayTitle, options, call);
311
+ + const choice = await ctx.ui.select(displayTitle, options, {
312
+ + ...call,
313
+ + nativePermission: {
314
+ + sessionID: request.sessionID,
315
+ + requestID: request.id,
316
+ + correlationId: request.correlationId,
317
+ + },
318
+ + });
319
+ if (choice === "Deny with feedback") {
320
+ - const feedback = await ctx.ui.input("Feedback", "Why are you denying this permission? (optional)", call);
321
+ + const feedback = await ctx.ui.input("Feedback", "Why are you denying this permission? (optional)", { ...call, required: false });
322
+ return {
323
+ requestID: request.id,
324
+ reply: "reject",
325
+ message: feedback || undefined,
326
+ + choice,
327
+ };
328
+ }
329
+ if (wider !== undefined && choice === wider.label) {
330
+ - return { requestID: request.id, reply: "always", always: [...wider.patterns] };
331
+ + return { requestID: request.id, reply: "always", always: [...wider.patterns], choice };
332
+ }
333
+ const replyMap = {
334
+ "Allow once": "once",
335
+ @@ -34,6 +42,7 @@
336
+ return {
337
+ requestID: request.id,
338
+ reply: choice ? replyMap[choice] : "reject",
339
+ + choice,
340
+ };
341
+ }
342
+ function formatRequestForDisplay(request) {
343
+ @@ -80,4 +89,4 @@
344
+ }
345
+ return parts.join("\n");
346
+ }
347
+ -//# sourceMappingURL=prompt.js.map
348
+
349
+ +//# sourceMappingURL=prompt.js.map
350
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/permission-system/service.js
351
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/permission-system/service.js
352
+ @@ -165,7 +165,10 @@
353
+ throw new DeniedError(deniedPatterns);
354
+ }
355
+ if (!needsAsk) {
356
+ - this.emitter.emitReplied(info.id, info.sessionID, "allow");
357
+ + this.emitter.emitReplied(info.id, info.sessionID, "allow", {
358
+ + correlationId: info.correlationId,
359
+ + source: "policy",
360
+ + });
361
+ return;
362
+ }
363
+ const pendingPromise = new Promise((resolve, reject) => {
364
+ @@ -196,7 +199,11 @@
365
+ }
366
+ this.pending.delete(input.requestID);
367
+ this.pendingOptions.delete(input.requestID);
368
+ - this.emitter.emitReplied(existing.info.id, existing.info.sessionID, input.reply);
369
+ + this.emitter.emitReplied(existing.info.id, existing.info.sessionID, input.reply, {
370
+ + correlationId: existing.info.correlationId,
371
+ + source: input.choice === undefined ? "system" : "user",
372
+ + ...(input.choice === undefined ? {} : { choice: input.choice }),
373
+ + });
374
+ if (input.reply === "reject") {
375
+ existing.reject(input.message ? new CorrectedError(input.message) : new RejectedError());
376
+ this.rejectPendingInSession(existing.info.sessionID);
377
+ @@ -247,7 +254,10 @@
378
+ }
379
+ this.pending.delete(requestID);
380
+ this.pendingOptions.delete(requestID);
381
+ - this.emitter.emitReplied(entry.info.id, entry.info.sessionID, "reject");
382
+ + this.emitter.emitReplied(entry.info.id, entry.info.sessionID, "reject", {
383
+ + correlationId: entry.info.correlationId,
384
+ + source: "cascade",
385
+ + });
386
+ entry.reject(new RejectedError());
387
+ }
388
+ }
389
+ @@ -263,9 +273,12 @@
390
+ }
391
+ this.pending.delete(requestID);
392
+ this.pendingOptions.delete(requestID);
393
+ - this.emitter.emitReplied(entry.info.id, entry.info.sessionID, "always");
394
+ + this.emitter.emitReplied(entry.info.id, entry.info.sessionID, "always", {
395
+ + correlationId: entry.info.correlationId,
396
+ + source: "cascade",
397
+ + });
398
+ entry.resolve();
399
+ }
400
+ }
401
+ }
402
+ -//# sourceMappingURL=service.js.map
403
+
404
+ +//# sourceMappingURL=service.js.map
405
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/permission-system/types.d.ts
406
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/permission-system/types.d.ts
407
+ @@ -19,6 +19,7 @@
408
+ export type Request = {
409
+ id: string;
410
+ sessionID: string;
411
+ + correlationId?: string;
412
+ permission: string;
413
+ patterns: string[];
414
+ always: string[];
415
+ @@ -42,6 +43,8 @@
416
+ message?: string;
417
+ /** With reply "always": the patterns to approve instead of the request's own (a wider scope). */
418
+ always?: string[];
419
+ + /** Actual UI choice, absent for system/cancellation paths. Metadata only. */
420
+ + choice?: string;
421
+ };
422
+ /** Error thrown when user rejects a permission request */
423
+ export declare class RejectedError extends Error {
424
+ @@ -68,4 +71,4 @@
425
+ resolve: () => void;
426
+ reject: (err: RejectedError | CorrectedError) => void;
427
+ };
428
+ -//# sourceMappingURL=types.d.ts.map
429
+
430
+ +//# sourceMappingURL=types.d.ts.map
431
+ --- a/@code-yeongyu/senpi/dist/modes/rpc/client-input-handler.js
432
+ +++ b/@code-yeongyu/senpi/dist/modes/rpc/client-input-handler.js
433
+ @@ -8,6 +8,7 @@
434
+ /** The same admission path serves classic stdio, shared sessions, and worker sessions. */
435
+ export async function handleClientInput(session, command, io) {
436
+ try {
437
+ + command = { ...command, ...clientMessageIdentity(command) };
438
+ const admissions = ClientAdmissions.forSession(session);
439
+ let disposition;
440
+ let responded = false;
441
+ @@ -123,4 +124,4 @@
442
+ });
443
+ }
444
+ }
445
+ -//# sourceMappingURL=client-input-handler.js.map
446
+
447
+ +//# sourceMappingURL=client-input-handler.js.map
448
+ --- a/@code-yeongyu/senpi/dist/modes/rpc/connection-handler.js
449
+ +++ b/@code-yeongyu/senpi/dist/modes/rpc/connection-handler.js
450
+ @@ -310,7 +310,8 @@
451
+ resolve(defaultValue);
452
+ };
453
+ opts?.signal?.addEventListener("abort", onAbort, { once: true });
454
+ - if (opts?.timeout) {
455
+ + // zgw: a required question degraded to dialogs has an infinite timeout; setTimeout(Infinity) fires at once.
456
+ + if (opts?.timeout && Number.isFinite(opts.timeout)) {
457
+ timeoutId = setTimeout(() => {
458
+ cleanup();
459
+ resolve(defaultValue);
460
+ @@ -331,12 +332,27 @@
461
+ * Create an extension UI context that uses the RPC protocol.
462
+ */
463
+ const createExtensionUIContext = () => ({
464
+ - question: (request, opts) => clientCapabilities?.includes(QUESTION_CAPABILITY)
465
+ + // zgw (bug 151): a binding made without a connection (a handoff reopen) asks the session's live connections.
466
+ + question: (request, opts) => clientCapabilities?.includes(QUESTION_CAPABILITY) || options.clientInfo?.sessionHasCapability?.(QUESTION_CAPABILITY)
467
+ ? questions.ask(request, opts)
468
+ : degradeQuestion(createExtensionUIContext(), request, opts),
469
+ - select: (title, options, opts) => createDialogPromise(opts, undefined, { method: "select", title, options, timeout: opts?.timeout, ...dialogCall(opts) }, (r) => ("cancelled" in r && r.cancelled ? undefined : "value" in r ? r.value : undefined)),
470
+ + select: (title, options, opts) => createDialogPromise(opts, undefined, {
471
+ + method: "select",
472
+ + title,
473
+ + options,
474
+ + timeout: opts?.timeout,
475
+ + ...dialogCall(opts),
476
+ + nativePermission: opts?.nativePermission ?? null,
477
+ + }, (r) => ("cancelled" in r && r.cancelled ? undefined : "value" in r ? r.value : undefined)),
478
+ confirm: (title, message, opts) => createDialogPromise(opts, false, { method: "confirm", title, message, timeout: opts?.timeout }, (r) => "cancelled" in r && r.cancelled ? false : "confirmed" in r ? r.confirmed : false),
479
+ - input: (title, placeholder, opts) => createDialogPromise(opts, undefined, { method: "input", title, placeholder, timeout: opts?.timeout, ...dialogCall(opts) }, (r) => ("cancelled" in r && r.cancelled ? undefined : "value" in r ? r.value : undefined)),
480
+ + input: (title, placeholder, opts) => createDialogPromise(opts, undefined, {
481
+ + method: "input",
482
+ + title,
483
+ + placeholder,
484
+ + timeout: opts?.timeout,
485
+ + ...dialogCall(opts),
486
+ + required: opts?.required,
487
+ + }, (r) => ("cancelled" in r && r.cancelled ? undefined : "value" in r ? r.value : undefined)),
488
+ notify(message, type) {
489
+ // Fire and forget - no response needed
490
+ output({
491
+ @@ -870,6 +886,8 @@
492
+ await handleClientInput(session, command, { output, promptCalls });
493
+ return undefined;
494
+ }
495
+ + case "resume_queued_input":
496
+ + return success(id, "resume_queued_input", session.resumeQueuedInput(command.clientInputId, command.queueEpoch));
497
+ case "abort":
498
+ if (command.intent === "stop" && !hasRole(INTERACTIVE_USER_CAPABILITY))
499
+ return error(id, "abort", "stop_requires_interactive_client: declare interactive_user with set_client_info first");
500
+ @@ -1673,4 +1691,4 @@
501
+ },
502
+ };
503
+ }
504
+ -//# sourceMappingURL=connection-handler.js.map
505
+
506
+ +//# sourceMappingURL=connection-handler.js.map
507
+ --- a/@code-yeongyu/senpi/dist/modes/rpc/connection-question-bridge.js
508
+ +++ b/@code-yeongyu/senpi/dist/modes/rpc/connection-question-bridge.js
509
+ @@ -76,7 +76,9 @@
510
+ const arm = () => {
511
+ clearTimeout(timer);
512
+ frame.deadlineAtMs = Date.now() + timeout;
513
+ - timer = setTimeout(() => finish("timed_out"), timeout);
514
+ + // zgw: a required question (infinite timeout) has no timer; setTimeout(Infinity) fires at once.
515
+ + if (Number.isFinite(timeout))
516
+ + timer = setTimeout(() => finish("timed_out"), timeout);
517
+ };
518
+ this.pending.set(id, {
519
+ frame,
520
+ @@ -174,4 +176,4 @@
521
+ unanswered,
522
+ };
523
+ }
524
+ -//# sourceMappingURL=connection-question-bridge.js.map
525
+
526
+ +//# sourceMappingURL=connection-question-bridge.js.map
527
+ --- a/@code-yeongyu/senpi/dist/modes/rpc/multi-session-host.js
528
+ +++ b/@code-yeongyu/senpi/dist/modes/rpc/multi-session-host.js
529
+ @@ -26,7 +26,7 @@
530
+ import { hostGeneration, hostInstanceId, protocolIdentity } from "./protocol-identity.js";
531
+ import { rpcCommandShapeError } from "./rpc-input-validation.js";
532
+ import { computeRuntimeBuildId, RUNTIME_IDENTITY_HANDOVER_CAPABILITY } from "./runtime-build-id.js";
533
+ -import { SessionCommandRouter } from "./session-command-router.js";
534
+ +import { markHostStopping, SessionCommandRouter } from "./session-command-router.js";
535
+ import { SessionEventWriter } from "./session-event-writer.js";
536
+ import { canonicalSessionPath } from "./session-path-key.js";
537
+ import { RpcSessionRegistry, RpcSessionRegistryError } from "./session-registry.js";
538
+ @@ -585,9 +585,10 @@
539
+ function registerShutdownSignals(shutdown) {
540
+ for (const signal of process.platform === "win32" ? ["SIGTERM"] : ["SIGTERM", "SIGHUP"]) {
541
+ process.on(signal, () => {
542
+ + markHostStopping();
543
+ killTrackedDetachedChildren();
544
+ void shutdown(signal === "SIGHUP" ? 129 : 143);
545
+ });
546
+ }
547
+ }
548
+ -//# sourceMappingURL=multi-session-host.js.map
549
+
550
+ +//# sourceMappingURL=multi-session-host.js.map
551
+ --- a/@code-yeongyu/senpi/dist/modes/rpc/rpc-input-validation.js
552
+ +++ b/@code-yeongyu/senpi/dist/modes/rpc/rpc-input-validation.js
553
+ @@ -75,8 +75,18 @@
554
+ const value = command;
555
+ if ((value.type === "prompt" || value.type === "steer" || value.type === "follow_up") &&
556
+ !clientMessageIdentitySchema.safeParse(value).success) {
557
+ - return "clientMessageId and clientTurnId must be non-empty strings of at most 256 characters.";
558
+ + return "clientInputId must be a non-empty string of at most 128 characters; clientMessageId and clientTurnId must be non-empty strings of at most 256 characters.";
559
+ + }
560
+ + if (value.type === "resume_queued_input" &&
561
+ + (typeof value.clientInputId !== "string" ||
562
+ + value.clientInputId.length === 0 ||
563
+ + value.clientInputId.length > 128)) {
564
+ + return "clientInputId must be a non-empty string of at most 128 characters.";
565
+ }
566
+ + if (value.type === "resume_queued_input" &&
567
+ + (typeof value.queueEpoch !== "string" || value.queueEpoch.length === 0 || value.queueEpoch.length > 128)) {
568
+ + return "resume_queued_input requires a valid queueEpoch.";
569
+ + }
570
+ if ((value.type === "dequeue_message" || value.type === "edit_queued_message") &&
571
+ !(typeof value.queuedId === "string" && value.queuedId.length > 0 && value.queuedId.length <= 256)) {
572
+ return "queuedId must be a non-empty string of at most 256 characters; read it from get_state or queue_update.";
573
+ @@ -269,4 +279,4 @@
574
+ }
575
+ return `RPC ${input.type} message exceeds ${MAX_RPC_MESSAGE_CHARACTERS} characters.`;
576
+ }
577
+ -//# sourceMappingURL=rpc-input-validation.js.map
578
+
579
+ +//# sourceMappingURL=rpc-input-validation.js.map
580
+ --- a/@code-yeongyu/senpi/dist/modes/rpc/rpc-session-state.js
581
+ +++ b/@code-yeongyu/senpi/dist/modes/rpc/rpc-session-state.js
582
+ @@ -32,6 +32,9 @@
583
+ paused: session.paused ?? false,
584
+ pausedNoticeCount: session.pausedNoticeCount ?? 0,
585
+ holds: session.holds ?? [],
586
+ + clientInputIdentity: true,
587
+ + atomicQueuedResume: true,
588
+ + queueEpoch: session.queueEpoch,
589
+ pendingQuestions: sessionQuestionBridges.get(session)?.pendingQuestions(),
590
+ pendingDialogs: sessionDialogRequests.get(session)?.snapshot() ?? [],
591
+ model: session.model,
592
+ @@ -82,4 +85,4 @@
593
+ scopedModels: session.scopedModels?.map((entry) => ({ ...entry })) ?? [],
594
+ };
595
+ }
596
+ -//# sourceMappingURL=rpc-session-state.js.map
597
+
598
+ +//# sourceMappingURL=rpc-session-state.js.map
599
+ --- a/@code-yeongyu/senpi/dist/modes/rpc/rpc-types.d.ts
600
+ +++ b/@code-yeongyu/senpi/dist/modes/rpc/rpc-types.d.ts
601
+ @@ -44,6 +44,7 @@
602
+ sessionTitlePrompt?: string | false;
603
+ expandPromptTemplates?: boolean;
604
+ unknownCommandAsText?: boolean;
605
+ + clientInputId?: string;
606
+ clientMessageId?: string;
607
+ clientTurnId?: string;
608
+ } | {
609
+ @@ -97,6 +98,11 @@
610
+ * dispatched and makes a retry with the same id a replay; without it the abort behaves exactly as before.
611
+ */
612
+ | {
613
+ + id?: string;
614
+ + type: "resume_queued_input";
615
+ + clientInputId: string;
616
+ + queueEpoch: string;
617
+ +} | {
618
+ id?: string;
619
+ type: "abort";
620
+ effectId?: string;
621
+ @@ -917,6 +923,9 @@
622
+ data?: unknown;
623
+ sourceCounts: Record<string, number>;
624
+ }[];
625
+ + clientInputIdentity: boolean;
626
+ + atomicQueuedResume: boolean;
627
+ + queueEpoch: string;
628
+ model?: Model<any>;
629
+ /**
630
+ * Model switch held until the next compaction, or `null` when no switch is held.
631
+ @@ -1215,6 +1224,14 @@
632
+ } | {
633
+ id?: string;
634
+ type: "response";
635
+ + command: "resume_queued_input";
636
+ + success: true;
637
+ + data: {
638
+ + disposition: "stale_epoch" | "not_queued" | "busy" | "scheduled";
639
+ + };
640
+ +} | {
641
+ + id?: string;
642
+ + type: "response";
643
+ command: "follow_up";
644
+ success: true;
645
+ data?: ClientMessageIdentity & {
646
+ @@ -1868,6 +1885,11 @@
647
+ type: "extension_ui_request";
648
+ id: string;
649
+ method: "select";
650
+ + nativePermission?: {
651
+ + sessionID: string;
652
+ + requestID: string;
653
+ + correlationId: string;
654
+ + } | null;
655
+ title: string;
656
+ options: string[];
657
+ timeout?: number;
658
+ @@ -1885,6 +1907,7 @@
659
+ type: "extension_ui_request";
660
+ id: string;
661
+ method: "input";
662
+ + required?: boolean;
663
+ title: string;
664
+ placeholder?: string;
665
+ timeout?: number;
666
+ @@ -2193,4 +2216,4 @@
667
+ to: string;
668
+ reason: string;
669
+ }
670
+ -//# sourceMappingURL=rpc-types.d.ts.map
671
+
672
+ +//# sourceMappingURL=rpc-types.d.ts.map
673
+ --- a/@code-yeongyu/senpi/dist/modes/rpc/session-command-router.js
674
+ +++ b/@code-yeongyu/senpi/dist/modes/rpc/session-command-router.js
675
+ @@ -1,6 +1,7 @@
676
+ import { VERSION } from "../../config.js";
677
+ import { subscriptionLimits } from "../../core/subscription-limits/limit-store.js";
678
+ import { detachConnection } from "./connection-detach.js";
679
+ +import { interruptRequiredQuestionsForHostShutdown } from "../../core/extensions/builtin/ask-user/resume.js";
680
+ import { buildRpcSessionState } from "./connection-handler.js";
681
+ import { CONNECTION_ROLE_FIXED, ConnectionRoles, roleConnection } from "./connection-roles.js";
682
+ import { answerEffectFromTranscript } from "./effect-commands.js";
683
+ @@ -26,6 +27,12 @@
684
+ import { selectSweepEvictions } from "./session-sweep.js";
685
+ import { unloadSession } from "./session-unload.js";
686
+ import { liveToolMediaScope, toolMediaPersister } from "./tool-media-store.js";
687
+ +// zgw bug 69: set once the host received its stop signal (SIGTERM/SIGHUP); a close after that is the host stopping,
688
+ +// not the user or the agent.
689
+ +let hostStopping = false;
690
+ +export function markHostStopping() {
691
+ + hostStopping = true;
692
+ +}
693
+ /** How often a draining host re-checks whether the work it is waiting for has settled. */
694
+ const DRAIN_SWEEP_MS = 50;
695
+ function error(id, command, code, data) {
696
+ @@ -666,6 +673,27 @@
697
+ if (opens.size === 0)
698
+ this.opensByConnection.delete(owner);
699
+ });
700
+ + }
701
+ + /**
702
+ + * zgw (bug 151): whether the session's clients advertised a capability, read when it is needed. A binding keeps the
703
+ + * capabilities of the connection that opened it, and a handoff reopen has none. Attached live connections decide;
704
+ + * with none attached, any live connection that advertised it does, since a question frame is replayed on attach.
705
+ + */
706
+ + sessionAdvertises(sessionId, capability) {
707
+ + let attached = false;
708
+ + for (const [connection, sessions] of this.sessionsByConnection) {
709
+ + if (!sessions.has(sessionId) || this.releasedConnections.has(connection))
710
+ + continue;
711
+ + attached = true;
712
+ + if (this.pendingCapabilities.get(connection)?.includes(capability))
713
+ + return true;
714
+ + }
715
+ + if (attached)
716
+ + return false;
717
+ + for (const [connection, capabilities] of this.pendingCapabilities)
718
+ + if (!this.releasedConnections.has(connection) && this.writer.hasRegisteredConnectionCapabilities(connection) && capabilities.includes(capability))
719
+ + return true;
720
+ + return false;
721
+ }
722
+ /**
723
+ * The successor's reopen of a handoff-parked session: the same open as an `open_session`, with no
724
+ @@ -677,8 +705,19 @@
725
+ reply: false,
726
+ defaults: false,
727
+ contextOverlay: entry.contextOverlay,
728
+ + releaseUnowned: true,
729
+ });
730
+ }
731
+ + /**
732
+ + * zgw (rotation after a handoff): the registry counts one attachment for every open, and an owner-less reopen has no
733
+ + * connection that could ever release it, so a later close by the real owner left the session open. Release it as a
734
+ + * detach: a retained entry stays open at zero attachments, exactly as when its last client leaves.
735
+ + */
736
+ + releaseUnownedReopen(sessionId) {
737
+ + if (this.draining || this.registry.peek(sessionId)?.retainOnDisconnect !== true)
738
+ + return false;
739
+ + return this.tryClaimClose(sessionId, { drainAttachments: false, detach: true }) !== undefined;
740
+ + }
741
+ async open(command, owner, options) {
742
+ // A parked session's reopen replays what its park recorded; a field it left empty stays empty
743
+ // rather than taking THIS host's default (senpi#3376 review).
744
+ @@ -796,6 +835,7 @@
745
+ }
746
+ },
747
+ connectionId: () => this.writer.currentConnection(),
748
+ + sessionHasCapability: (capability) => this.sessionAdvertises(openedSession.sessionId, capability),
749
+ },
750
+ }));
751
+ if (entry.worker)
752
+ @@ -842,6 +882,8 @@
753
+ this.releaseOwnerAttachment(owner, openedSession.sessionId);
754
+ await this.releaseOwnedSession(openedSession.sessionId);
755
+ }
756
+ + if (owner === undefined && options?.releaseUnowned === true)
757
+ + this.releaseUnownedReopen(openedSession.sessionId);
758
+ const replyState = entry.worker?.snapshot?.state ?? (entry.runtime ? buildRpcSessionState(entry.runtime.session) : undefined);
759
+ if (!replyState)
760
+ throw new Error("Session state was not created");
761
+ @@ -1107,6 +1149,10 @@
762
+ * second response or an escaped rejection.
763
+ */
764
+ async finalizeClose(sessionId, binding, terminal) {
765
+ + // zgw bug 69: before the binding drops its UI requests, keep live required questions resumable.
766
+ + const runtime = hostStopping ? this.registry.peek(sessionId)?.runtime : undefined;
767
+ + if (runtime)
768
+ + interruptRequiredQuestionsForHostShutdown(runtime.session.sessionManager);
769
+ const finalization = this.finalizations.get(sessionId) ?? this.createFinalization();
770
+ this.finalizations.set(sessionId, finalization);
771
+ try {
772
+ @@ -1159,4 +1205,4 @@
773
+ : `${RPC_ERROR_OPEN_FAILED}: ${cause.message}`;
774
+ }
775
+ }
776
+ -//# sourceMappingURL=session-command-router.js.map
777
+
778
+ +//# sourceMappingURL=session-command-router.js.map