@sayknow-cli/coding-agent 0.3.12 → 0.3.15

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 (248) hide show
  1. package/dist/types/autoresearch/tools/init-experiment.d.ts +1 -1
  2. package/dist/types/autoresearch/tools/log-experiment.d.ts +2 -2
  3. package/dist/types/cli/classify-install-target.d.ts +0 -10
  4. package/dist/types/cli/daemon-cli.d.ts +2 -0
  5. package/dist/types/cli/initial-message.d.ts +1 -1
  6. package/dist/types/cli/malloc-env-guard.d.ts +65 -0
  7. package/dist/types/cli/malloc-env-guard.test.d.ts +1 -0
  8. package/dist/types/cli/notify-cli.d.ts +3 -1
  9. package/dist/types/cli/session-picker.d.ts +4 -3
  10. package/dist/types/cli/update-cli.d.ts +22 -3
  11. package/dist/types/cli.d.ts +5 -0
  12. package/dist/types/commands/daemon.d.ts +6 -2
  13. package/dist/types/commands/launch.d.ts +1 -0
  14. package/dist/types/commands/notify.d.ts +6 -0
  15. package/dist/types/commands/web-search.d.ts +1 -1
  16. package/dist/types/commit/agentic/tools/propose-changelog.d.ts +4 -4
  17. package/dist/types/commit/agentic/tools/propose-commit.d.ts +9 -9
  18. package/dist/types/commit/agentic/tools/schemas.d.ts +9 -9
  19. package/dist/types/commit/agentic/tools/split-commit.d.ts +9 -9
  20. package/dist/types/commit/analysis/conventional.d.ts +1 -1
  21. package/dist/types/commit/analysis/summary.d.ts +1 -1
  22. package/dist/types/commit/changelog/generate.d.ts +3 -3
  23. package/dist/types/commit/changelog/index.d.ts +2 -2
  24. package/dist/types/commit/map-reduce/map-phase.d.ts +1 -1
  25. package/dist/types/commit/map-reduce/reduce-phase.d.ts +1 -1
  26. package/dist/types/commit/shared-llm.d.ts +9 -9
  27. package/dist/types/config/keybindings.d.ts +134 -129
  28. package/dist/types/config/model-profile-activation.d.ts +1 -0
  29. package/dist/types/config/model-registry.d.ts +35 -35
  30. package/dist/types/config/models-config-schema.d.ts +111 -111
  31. package/dist/types/config/settings-schema.d.ts +56 -16
  32. package/dist/types/config/settings.d.ts +2 -0
  33. package/dist/types/coordinator/contract.d.ts +1 -1
  34. package/dist/types/coordinator-mcp/model-preset.d.ts +45 -0
  35. package/dist/types/coordinator-mcp/policy.d.ts +3 -0
  36. package/dist/types/coordinator-mcp/server.d.ts +79 -6
  37. package/dist/types/coordinator-mcp/session-reaper.d.ts +53 -0
  38. package/dist/types/daemon/control-types.d.ts +10 -0
  39. package/dist/types/daemon/operator-contract.d.ts +47 -0
  40. package/dist/types/defaults/skc/extensions/grok-cli-vendor/src/models/catalog.d.ts +4 -0
  41. package/dist/types/defaults/skc/extensions/grok-cli-vendor/src/shared/errors.d.ts +12 -12
  42. package/dist/types/discovery/helpers.d.ts +1 -1
  43. package/dist/types/discovery/substitute-plugin-root.d.ts +0 -4
  44. package/dist/types/edit/modes/patch.d.ts +2 -2
  45. package/dist/types/extensibility/extensions/types.d.ts +3 -1
  46. package/dist/types/goals/tools/goal-tool.d.ts +6 -6
  47. package/dist/types/i18n/messages/en.d.ts +8 -8
  48. package/dist/types/internal-urls/local-protocol.d.ts +13 -9
  49. package/dist/types/lsp/index.d.ts +9 -9
  50. package/dist/types/lsp/types.d.ts +9 -9
  51. package/dist/types/main.d.ts +66 -2
  52. package/dist/types/modes/acp/acp-agent.d.ts +19 -2
  53. package/dist/types/modes/acp/acp-mode.d.ts +12 -0
  54. package/dist/types/modes/components/assistant-message.d.ts +1 -1
  55. package/dist/types/modes/components/custom-editor.d.ts +2 -0
  56. package/dist/types/modes/components/hook-selector.d.ts +5 -0
  57. package/dist/types/modes/components/irc-sidebar.d.ts +32 -0
  58. package/dist/types/modes/components/pet-selector.d.ts +11 -0
  59. package/dist/types/modes/components/sayknow-pet-widget.d.ts +59 -0
  60. package/dist/types/modes/components/session-selector.d.ts +22 -9
  61. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  62. package/dist/types/modes/components/status-line/types.d.ts +1 -1
  63. package/dist/types/modes/components/status-line.d.ts +0 -10
  64. package/dist/types/modes/components/tool-execution.d.ts +1 -0
  65. package/dist/types/modes/components/user-message.d.ts +2 -1
  66. package/dist/types/modes/controllers/event-controller.d.ts +4 -0
  67. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  68. package/dist/types/modes/interactive-mode.d.ts +24 -2
  69. package/dist/types/modes/irc-observation-ledger.d.ts +22 -0
  70. package/dist/types/modes/print-mode.d.ts +3 -4
  71. package/dist/types/modes/rpc/rpc-client.d.ts +7 -1
  72. package/dist/types/modes/rpc/rpc-mode.d.ts +2 -1
  73. package/dist/types/modes/rpc/rpc-types.d.ts +17 -1
  74. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +8 -0
  75. package/dist/types/modes/shared/agent-wire/unattended-run-controller.d.ts +5 -0
  76. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +45 -0
  77. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +42 -2
  78. package/dist/types/modes/theme/defaults/index.d.ts +568 -568
  79. package/dist/types/modes/types.d.ts +28 -2
  80. package/dist/types/modes/utils/abort-message.d.ts +1 -1
  81. package/dist/types/modes/utils/context-usage.d.ts +4 -19
  82. package/dist/types/modes/utils/irc-message.d.ts +38 -0
  83. package/dist/types/modes/utils/ui-helpers.d.ts +13 -4
  84. package/dist/types/notifications/config.d.ts +34 -0
  85. package/dist/types/notifications/daemon-paths.d.ts +7 -0
  86. package/dist/types/notifications/index.d.ts +27 -0
  87. package/dist/types/notifications/lifecycle-control-runtime.d.ts +12 -4
  88. package/dist/types/notifications/notification-service.d.ts +144 -0
  89. package/dist/types/notifications/rate-limit-pool.d.ts +27 -9
  90. package/dist/types/notifications/telegram-daemon-control.d.ts +6 -0
  91. package/dist/types/notifications/telegram-daemon.d.ts +130 -3
  92. package/dist/types/notifications/telegram-reference.d.ts +19 -2
  93. package/dist/types/notifications/topic-registry.d.ts +24 -4
  94. package/dist/types/registry/agent-registry.d.ts +2 -0
  95. package/dist/types/rlm/index.d.ts +1 -1
  96. package/dist/types/sdk.d.ts +2 -0
  97. package/dist/types/secrets/obfuscator.d.ts +1 -1
  98. package/dist/types/session/agent-session.d.ts +40 -3
  99. package/dist/types/session/cache-economics.d.ts +79 -0
  100. package/dist/types/session/context-estimation.d.ts +31 -0
  101. package/dist/types/session/provider-safety-stop.d.ts +5 -0
  102. package/dist/types/session/session-manager.d.ts +124 -4
  103. package/dist/types/session/session-storage.d.ts +155 -13
  104. package/dist/types/setup/credential-auto-import.d.ts +2 -2
  105. package/dist/types/skc-runtime/session-state-sidecar.d.ts +34 -3
  106. package/dist/types/skc-runtime/state-schema.d.ts +19 -19
  107. package/dist/types/skc-runtime/tmux-common.d.ts +6 -0
  108. package/dist/types/skc-runtime/tmux-owner-isolation-cli.d.ts +5 -0
  109. package/dist/types/skc-runtime/tmux-owner-isolation.d.ts +309 -0
  110. package/dist/types/skc-runtime/tmux-sessions.d.ts +49 -17
  111. package/dist/types/task/agents.d.ts +2 -2
  112. package/dist/types/task/executor.d.ts +3 -0
  113. package/dist/types/task/receipt.d.ts +3 -1
  114. package/dist/types/task/types.d.ts +35 -27
  115. package/dist/types/tools/ask-answer-registry.d.ts +5 -0
  116. package/dist/types/tools/bash.d.ts +1 -1
  117. package/dist/types/tools/browser/tab-supervisor.d.ts +6 -0
  118. package/dist/types/tools/browser.d.ts +28 -28
  119. package/dist/types/tools/computer.d.ts +20 -20
  120. package/dist/types/tools/cron.d.ts +2 -2
  121. package/dist/types/tools/debug.d.ts +40 -40
  122. package/dist/types/tools/gh.d.ts +10 -10
  123. package/dist/types/tools/image-gen.d.ts +2 -2
  124. package/dist/types/tools/index.d.ts +58 -6
  125. package/dist/types/tools/monitor.d.ts +6 -6
  126. package/dist/types/tools/skill-discovery.d.ts +4 -4
  127. package/dist/types/tools/subagent.d.ts +12 -12
  128. package/dist/types/tools/telegram-send.d.ts +7 -0
  129. package/dist/types/tools/todo-write.d.ts +8 -8
  130. package/dist/types/utils/external-editor.d.ts +2 -0
  131. package/dist/types/web/insane/bridge.d.ts +9 -9
  132. package/dist/types/web/search/index.d.ts +4 -4
  133. package/package.json +8 -8
  134. package/scripts/g004-tmux-smoke.ts +196 -36
  135. package/src/cli/daemon-cli.ts +19 -29
  136. package/src/cli/malloc-env-guard.test.ts +104 -0
  137. package/src/cli/malloc-env-guard.ts +149 -0
  138. package/src/cli/notify-cli.ts +78 -14
  139. package/src/cli/session-picker.ts +23 -40
  140. package/src/cli/update-cli.ts +56 -37
  141. package/src/cli.ts +51 -10
  142. package/src/commands/daemon.ts +19 -5
  143. package/src/commands/harness.ts +515 -47
  144. package/src/commands/launch.ts +11 -3
  145. package/src/commands/notify.ts +5 -1
  146. package/src/commands/session.ts +6 -1
  147. package/src/config/keybindings.ts +5 -0
  148. package/src/config/model-profile-activation.ts +1 -1
  149. package/src/config/model-profiles.ts +14 -14
  150. package/src/config/settings-schema.ts +41 -1
  151. package/src/config/settings.ts +96 -45
  152. package/src/coordinator/contract.ts +1 -0
  153. package/src/coordinator-mcp/model-preset.ts +100 -0
  154. package/src/coordinator-mcp/policy.ts +30 -0
  155. package/src/coordinator-mcp/server.ts +2958 -430
  156. package/src/coordinator-mcp/session-reaper.ts +129 -0
  157. package/src/daemon/control-types.ts +11 -0
  158. package/src/daemon/operator-contract.ts +121 -0
  159. package/src/defaults/skc/extensions/grok-cli-vendor/src/models/catalog.ts +57 -17
  160. package/src/defaults/skc/extensions/grok-cli-vendor/src/payload/sanitize.ts +15 -2
  161. package/src/defaults/skc/extensions/grok-cli-vendor/src/provider/register.ts +5 -1
  162. package/src/extensibility/extensions/runner.ts +1 -1
  163. package/src/extensibility/extensions/types.ts +3 -1
  164. package/src/internal-urls/docs-index.generated.ts +10 -9
  165. package/src/internal-urls/local-protocol.ts +29 -10
  166. package/src/main.ts +301 -80
  167. package/src/modes/acp/acp-agent.ts +730 -100
  168. package/src/modes/acp/acp-mode.ts +29 -2
  169. package/src/modes/components/assistant-message.ts +32 -3
  170. package/src/modes/components/bash-execution.ts +43 -13
  171. package/src/modes/components/custom-editor.ts +5 -0
  172. package/src/modes/components/footer.ts +3 -3
  173. package/src/modes/components/hook-selector.ts +12 -0
  174. package/src/modes/components/irc-sidebar.ts +206 -0
  175. package/src/modes/components/pet-selector.ts +55 -0
  176. package/src/modes/components/sayknow-pet-widget.ts +360 -0
  177. package/src/modes/components/session-selector.ts +222 -221
  178. package/src/modes/components/settings-selector.ts +10 -0
  179. package/src/modes/components/status-line/segments.ts +14 -7
  180. package/src/modes/components/status-line/types.ts +1 -1
  181. package/src/modes/components/status-line.ts +4 -171
  182. package/src/modes/components/tool-execution.ts +47 -6
  183. package/src/modes/components/user-message.ts +26 -9
  184. package/src/modes/controllers/command-controller.ts +28 -2
  185. package/src/modes/controllers/event-controller.ts +159 -36
  186. package/src/modes/controllers/extension-ui-controller.ts +37 -18
  187. package/src/modes/controllers/input-controller.ts +8 -2
  188. package/src/modes/controllers/selector-controller.ts +62 -15
  189. package/src/modes/interactive-mode.ts +199 -7
  190. package/src/modes/irc-observation-ledger.ts +127 -0
  191. package/src/modes/print-mode.ts +228 -92
  192. package/src/modes/rpc/rpc-client.ts +58 -1
  193. package/src/modes/rpc/rpc-mode.ts +286 -85
  194. package/src/modes/rpc/rpc-types.ts +15 -1
  195. package/src/modes/shared/agent-wire/command-dispatch.ts +33 -1
  196. package/src/modes/shared/agent-wire/command-validation.ts +10 -0
  197. package/src/modes/shared/agent-wire/deep-interview-gate.ts +45 -2
  198. package/src/modes/shared/agent-wire/scopes.ts +1 -0
  199. package/src/modes/shared/agent-wire/unattended-session.ts +288 -1
  200. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +130 -20
  201. package/src/modes/types.ts +34 -2
  202. package/src/modes/utils/context-usage.ts +56 -92
  203. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  204. package/src/modes/utils/irc-message.ts +138 -0
  205. package/src/modes/utils/ui-helpers.ts +216 -50
  206. package/src/notifications/config.ts +44 -0
  207. package/src/notifications/daemon-paths.ts +8 -0
  208. package/src/notifications/index.ts +535 -75
  209. package/src/notifications/lifecycle-control-runtime.ts +930 -77
  210. package/src/notifications/notification-service.ts +710 -0
  211. package/src/notifications/rate-limit-pool.ts +46 -12
  212. package/src/notifications/telegram-daemon-control.ts +27 -2
  213. package/src/notifications/telegram-daemon.ts +635 -94
  214. package/src/notifications/telegram-reference.ts +73 -13
  215. package/src/notifications/topic-registry.ts +86 -9
  216. package/src/prompts/system/irc-peer-roster.md +1 -0
  217. package/src/prompts/system/subagent-system-prompt.md +1 -0
  218. package/src/prompts/system/system-prompt.md +1 -0
  219. package/src/prompts/tools/irc.md +1 -1
  220. package/src/registry/agent-registry.ts +3 -0
  221. package/src/sdk.ts +52 -1
  222. package/src/secrets/obfuscator.ts +4 -2
  223. package/src/session/agent-session.ts +670 -217
  224. package/src/session/cache-economics.ts +275 -0
  225. package/src/session/context-estimation.ts +91 -0
  226. package/src/session/provider-safety-stop.ts +9 -0
  227. package/src/session/session-dump-format.ts +6 -0
  228. package/src/session/session-manager.ts +809 -39
  229. package/src/session/session-storage.ts +582 -38
  230. package/src/skc-runtime/session-state-sidecar.ts +756 -135
  231. package/src/skc-runtime/tmux-common.ts +26 -7
  232. package/src/skc-runtime/tmux-gc.ts +52 -9
  233. package/src/skc-runtime/tmux-owner-isolation-cli.ts +185 -0
  234. package/src/skc-runtime/tmux-owner-isolation.ts +2060 -0
  235. package/src/skc-runtime/tmux-sessions.ts +871 -68
  236. package/src/slash-commands/builtin-registry.ts +153 -9
  237. package/src/slash-commands/helpers/context-report.ts +26 -4
  238. package/src/task/executor.ts +12 -3
  239. package/src/task/index.ts +8 -0
  240. package/src/task/receipt.ts +7 -2
  241. package/src/task/types.ts +26 -0
  242. package/src/tools/ask-answer-registry.ts +25 -0
  243. package/src/tools/browser/tab-supervisor.ts +63 -6
  244. package/src/tools/browser.ts +8 -3
  245. package/src/tools/index.ts +81 -6
  246. package/src/tools/sqlite-reader.ts +8 -4
  247. package/src/tui/output-block.ts +18 -6
  248. package/src/utils/external-editor.ts +6 -1
@@ -44,6 +44,8 @@ export interface AskGateQuestion {
44
44
  deepInterview?: AskGateDeepInterviewState;
45
45
  /** Override the emitted workflow gate address for non-deep-interview ask prompts. */
46
46
  workflowGate?: AskGateWorkflowGateMeta;
47
+ allowEmpty?: boolean;
48
+ navigationLabel?: "Next" | "Done";
47
49
  }
48
50
 
49
51
  export interface AskGateResult {
@@ -119,7 +121,7 @@ function questionAnswerSchema(question: AskGateQuestion, labels: string[]): RpcJ
119
121
  const selectedBase: RpcJsonSchema = { type: "array", items: selectedItems, uniqueItems: true };
120
122
  const selectedOnly: RpcJsonSchema = {
121
123
  ...selectedBase,
122
- minItems: 1,
124
+ minItems: question.allowEmpty ? 0 : 1,
123
125
  ...(multi ? {} : { maxItems: 1 }),
124
126
  };
125
127
  const selectedWithOther: RpcJsonSchema = {
@@ -216,6 +218,8 @@ export function questionToGate(question: AskGateQuestion): OpenGateInput {
216
218
  options: labels,
217
219
  other_option: GATE_OTHER_OPTION,
218
220
  clarification_action: "clarify",
221
+ allow_empty: question.allowEmpty === true,
222
+ navigation_label: question.navigationLabel,
219
223
  ...(question.deepInterview
220
224
  ? structuredDeepInterviewState(question.deepInterview)
221
225
  : deepInterviewQuestionState(question.question)),
@@ -280,12 +284,21 @@ export function gateAnswerToResult(question: AskGateQuestion, answer: unknown):
280
284
  }
281
285
  const other = answer.other === true;
282
286
  const totalPicks = deduped.length + (other ? 1 : 0);
283
- if (totalPicks === 0) {
287
+ if (totalPicks === 0 && !question.allowEmpty) {
284
288
  throw new DeepInterviewGateError(
285
289
  "empty_selection",
286
290
  "at least one option (or the free-text other) must be selected",
287
291
  );
288
292
  }
293
+ if (totalPicks === 0) {
294
+ return {
295
+ id: question.id,
296
+ question: question.question,
297
+ options: labels,
298
+ multi,
299
+ selectedOptions: [],
300
+ };
301
+ }
289
302
  if (!multi && totalPicks > 1) {
290
303
  throw new DeepInterviewGateError("multi_not_allowed", "this question accepts a single selection");
291
304
  }
@@ -302,6 +315,36 @@ export function gateAnswerToResult(question: AskGateQuestion, answer: unknown):
302
315
  };
303
316
  }
304
317
 
318
+ /**
319
+ * Classify an accepted ask-domain gate answer independently of its transport.
320
+ * Schema-invalid and semantically invalid answers throw, so callers leave the
321
+ * durable gate pending rather than treating them as a non-committing answer.
322
+ */
323
+ export function classifyAskGateDisposition(
324
+ gate: Pick<OpenGateInput, "context" | "options">,
325
+ answer: unknown,
326
+ ): "commit" | "resolve_without_commit" {
327
+ const state = gate.context?.stage_state;
328
+ if (typeof state !== "object" || state === null) return "commit";
329
+ const questionId = (state as Record<string, unknown>).question_id;
330
+ if (typeof questionId !== "string") return "commit";
331
+ const question: AskGateQuestion = {
332
+ id: questionId,
333
+ question: typeof gate.context?.prompt === "string" ? gate.context.prompt : questionId,
334
+ options: (gate.options ?? []).map(option => ({
335
+ label: typeof option.value === "string" ? option.value : option.label,
336
+ })),
337
+ multi: (state as Record<string, unknown>).multi === true,
338
+ allowEmpty: (state as Record<string, unknown>).allow_empty === true,
339
+ };
340
+ if (typeof answer === "object" && answer !== null && (answer as Record<string, unknown>).action === "clarify") {
341
+ gateAnswerToResult(question, answer);
342
+ return "resolve_without_commit";
343
+ }
344
+ const result = gateAnswerToResult(question, answer);
345
+ return result.selectedOptions.length === 0 && result.customInput === undefined ? "resolve_without_commit" : "commit";
346
+ }
347
+
305
348
  /** Convenience: map a batch of ask questions to gate open-inputs. */
306
349
  export function questionsToGates(questions: AskGateQuestion[]): OpenGateInput[] {
307
350
  return questions.map(questionToGate);
@@ -49,6 +49,7 @@ const RPC_COMMAND_SCOPE_REGISTRY: Record<RpcCommandType, BridgeCommandScope> = {
49
49
  get_pending_workflow_gates: "message:read",
50
50
  set_capabilities: "control",
51
51
  set_model: "model",
52
+ set_default_model_selection: "model",
52
53
  cycle_model: "model",
53
54
  get_available_models: "model",
54
55
  set_thinking_level: "model",
@@ -15,6 +15,7 @@
15
15
  * RPC server can route `negotiate_unattended` + `workflow_gate_response` here.
16
16
  */
17
17
  import type { Model } from "@sayknow-cli/ai";
18
+ import type { AskSelectedAckOutcome } from "../../../tools";
18
19
  import type {
19
20
  RpcCommand,
20
21
  RpcUnattendedAccepted,
@@ -30,7 +31,53 @@ import {
30
31
  type UnattendedAuditEvent,
31
32
  UnattendedRunController,
32
33
  } from "./unattended-run-controller";
33
- import { type GateStore, MemoryGateStore, type OpenGateInput, WorkflowGateBroker } from "./workflow-gate-broker";
34
+ import {
35
+ type GateStore,
36
+ MemoryGateStore,
37
+ type OpenGateInput,
38
+ type PersistedAckPolicy,
39
+ type PersistedGate,
40
+ type PersistedSemanticDisposition,
41
+ WorkflowGateBroker,
42
+ } from "./workflow-gate-broker";
43
+
44
+ export interface WorkflowGateTerminalController {
45
+ completeGateInteractions(gateId: string): void | Promise<void>;
46
+ cancelGateInteractions(gateId: string, reason: string): void | Promise<void>;
47
+ }
48
+
49
+ export interface WorkflowGateTerminalEvent {
50
+ gateId: string;
51
+ kind: "accepted" | "cancelled";
52
+ origin?: "rpc" | "bridge" | "other" | "telegram_notification";
53
+ reason?: string;
54
+ }
55
+
56
+ export interface NotificationGateResolutionOptions {
57
+ interactionActionId: string;
58
+ replyReceiptId: string;
59
+ answerJson: string;
60
+ idempotencyKey?: string;
61
+ requestSelectedAck(input: {
62
+ replyReceiptId: string;
63
+ actionId: string;
64
+ commitKey: string;
65
+ daemonDeadlineAt: number;
66
+ hostTimeoutMs: number;
67
+ }): Promise<AskSelectedAckOutcome>;
68
+ resolveClaim(): void;
69
+ closeClaimInvalid(reason: string): void;
70
+ }
71
+
72
+ export interface AskSelectedAckRecoveryParticipant {
73
+ requestRecoveredAskSelectedAck(input: {
74
+ sessionId: string;
75
+ actionId: string;
76
+ commitKey: string;
77
+ deadlineAt: number;
78
+ hostTimeoutMs: number;
79
+ }): Promise<AskSelectedAckOutcome>;
80
+ }
34
81
 
35
82
  /**
36
83
  * RPC commands that perform agent/tool work and therefore consume one unit of the
@@ -66,7 +113,13 @@ export interface WorkflowGateEmitter {
66
113
  */
67
114
  onGateEmitted?(listener: (gate: RpcWorkflowGate) => void): () => void;
68
115
  resolveGate?(response: RpcWorkflowGateResponse): Promise<RpcWorkflowGateResolution>;
116
+ resolveGateFromNotification?(
117
+ response: RpcWorkflowGateResponse,
118
+ options: NotificationGateResolutionOptions,
119
+ ): Promise<RpcWorkflowGateResolution>;
120
+ registerGateTerminalController?(controller: WorkflowGateTerminalController): () => void;
69
121
  listPendingGates?(): RpcWorkflowGate[];
122
+ setAckRecoveryParticipant?(participant: AskSelectedAckRecoveryParticipant | null): void;
70
123
  }
71
124
 
72
125
  export interface UnattendedSessionOptions {
@@ -91,6 +144,12 @@ export class UnattendedSessionControlPlane implements RpcUnattendedControlPlane,
91
144
  readonly #pending = new Map<string, { resolve: (answer: unknown) => void; reject: (err: Error) => void }>();
92
145
  readonly #earlyAnswers = new Map<string, unknown>();
93
146
  readonly #gateListeners = new Set<(gate: RpcWorkflowGate) => void>();
147
+ #terminalController: WorkflowGateTerminalController | undefined;
148
+ #brokerReady = false;
149
+ #recoveryPromise: Promise<void> | undefined;
150
+ #disposed = false;
151
+ #recoveryParticipant: AskSelectedAckRecoveryParticipant | undefined;
152
+ #participantReady = Promise.withResolvers<void>();
94
153
 
95
154
  constructor(private readonly opts: UnattendedSessionOptions) {}
96
155
 
@@ -108,6 +167,38 @@ export class UnattendedSessionControlPlane implements RpcUnattendedControlPlane,
108
167
  return this.#controller;
109
168
  }
110
169
 
170
+ registerGateTerminalController(controller: WorkflowGateTerminalController): () => void {
171
+ if (this.#terminalController && this.#terminalController !== controller) {
172
+ throw new Error("a workflow gate terminal controller is already registered");
173
+ }
174
+ this.#terminalController = controller;
175
+ return () => {
176
+ if (this.#terminalController === controller) this.#terminalController = undefined;
177
+ };
178
+ }
179
+
180
+ setAckRecoveryParticipant(participant: AskSelectedAckRecoveryParticipant | null): void {
181
+ this.#recoveryParticipant = participant ?? undefined;
182
+ if (participant) this.#participantReady.resolve();
183
+ void this.startRecoveryOnce();
184
+ }
185
+
186
+ async #completeGateInteractions(gateId: string): Promise<void> {
187
+ try {
188
+ await this.#terminalController?.completeGateInteractions(gateId);
189
+ } catch (error) {
190
+ this.opts.audit?.({ event: "gate_interaction_completion_failed", gate_id: gateId, error: String(error) });
191
+ }
192
+ }
193
+
194
+ async #cancelGateInteractions(gateId: string, reason: string): Promise<void> {
195
+ try {
196
+ await this.#terminalController?.cancelGateInteractions(gateId, reason);
197
+ } catch (error) {
198
+ this.opts.audit?.({ event: "gate_interaction_cancellation_failed", gate_id: gateId, error: String(error) });
199
+ }
200
+ }
201
+
111
202
  negotiate(declaration: RpcUnattendedDeclaration): RpcUnattendedAccepted {
112
203
  const controller = UnattendedRunController.negotiate(declaration, {
113
204
  runId: this.opts.runId,
@@ -140,7 +231,11 @@ export class UnattendedSessionControlPlane implements RpcUnattendedControlPlane,
140
231
  // openGate returns and the caller starts awaiting.
141
232
  this.#earlyAnswers.set(gate.gate_id, answer);
142
233
  },
234
+ finalizeAccepted: record => this.#finalizeAcceptedGate(record),
143
235
  });
236
+ this.#brokerReady = true;
237
+ void this.startRecoveryOnce();
238
+
144
239
  return {
145
240
  run_id: this.opts.runId,
146
241
  actor: controller.actor,
@@ -190,12 +285,203 @@ export class UnattendedSessionControlPlane implements RpcUnattendedControlPlane,
190
285
  }
191
286
 
192
287
  resolveGate(response: RpcWorkflowGateResponse): Promise<RpcWorkflowGateResolution> {
288
+ if (this.#disposed) return Promise.reject(new Error("unattended session is disposed"));
193
289
  if (!this.#broker) {
194
290
  return Promise.reject(new Error("workflow gates are not available until unattended mode is negotiated"));
195
291
  }
196
292
  return this.#broker.resolve(response);
197
293
  }
198
294
 
295
+ async resolveGateFromNotification(
296
+ response: RpcWorkflowGateResponse,
297
+ options: NotificationGateResolutionOptions,
298
+ ): Promise<RpcWorkflowGateResolution> {
299
+ let claimSettled = false;
300
+ const resolveClaim = () => {
301
+ if (claimSettled) return;
302
+ claimSettled = true;
303
+ try {
304
+ options.resolveClaim();
305
+ } catch (error) {
306
+ this.opts.audit?.({
307
+ event: "notification_claim_resolution_failed",
308
+ gate_id: response.gate_id,
309
+ error: String(error),
310
+ });
311
+ }
312
+ };
313
+ const closeClaimInvalid = (reason: string) => {
314
+ if (claimSettled) return;
315
+ claimSettled = true;
316
+ try {
317
+ options.closeClaimInvalid(reason);
318
+ } catch (error) {
319
+ this.opts.audit?.({
320
+ event: "notification_claim_close_failed",
321
+ gate_id: response.gate_id,
322
+ error: String(error),
323
+ });
324
+ }
325
+ };
326
+ if (this.#disposed) {
327
+ closeClaimInvalid("unattended_session_disposed");
328
+ throw new Error("unattended session is disposed");
329
+ }
330
+ if (!this.#broker) {
331
+ closeClaimInvalid("workflow_gate_unavailable");
332
+ throw new Error("workflow gates are not available until unattended mode is negotiated");
333
+ }
334
+ try {
335
+ JSON.parse(options.answerJson);
336
+ } catch {
337
+ closeClaimInvalid("invalid_structured_answer");
338
+ return {
339
+ gate_id: response.gate_id,
340
+ status: "rejected",
341
+ answer_hash: "",
342
+ resolved_at: new Date().toISOString(),
343
+ error: this.#broker.validationError(response.gate_id, "json_parse", "invalid structured answer"),
344
+ };
345
+ }
346
+ let disposition: PersistedSemanticDisposition;
347
+ try {
348
+ disposition = this.#broker.classifyDisposition(response.gate_id, response.answer);
349
+ } catch (error) {
350
+ closeClaimInvalid(error instanceof Error ? error.message : "invalid_answer");
351
+ return {
352
+ gate_id: response.gate_id,
353
+ status: "rejected",
354
+ answer_hash: "",
355
+ resolved_at: new Date().toISOString(),
356
+ error: this.#broker.validationError(
357
+ response.gate_id,
358
+ "semantic_disposition",
359
+ error instanceof Error ? error.message : "invalid answer",
360
+ ),
361
+ };
362
+ }
363
+
364
+ const commitKey = `${response.gate_id}:${options.idempotencyKey ?? options.replyReceiptId}`;
365
+ const ackPolicy: PersistedAckPolicy =
366
+ disposition === "commit"
367
+ ? {
368
+ kind: "telegram_selected_v1",
369
+ commitKey,
370
+ actionId: options.interactionActionId,
371
+ state: "pending",
372
+ updatedAt: new Date().toISOString(),
373
+ }
374
+ : { kind: "none", reason: "semantic_noncommit" };
375
+ let resolution: RpcWorkflowGateResolution;
376
+ try {
377
+ resolution = await this.#broker.resolve(response, {
378
+ resolutionOrigin: { kind: "telegram_notification", interactionActionId: options.interactionActionId },
379
+ ackPolicy,
380
+ semanticDisposition: disposition,
381
+
382
+ beforeAdvance: async () => {
383
+ if (ackPolicy.kind === "telegram_selected_v1") {
384
+ this.#broker?.updateAckPolicy(response.gate_id, {
385
+ ...ackPolicy,
386
+ state: "attempt_started",
387
+ updatedAt: new Date().toISOString(),
388
+ });
389
+ let outcome: AskSelectedAckOutcome;
390
+ try {
391
+ outcome = await options.requestSelectedAck({
392
+ replyReceiptId: options.replyReceiptId,
393
+ actionId: options.interactionActionId,
394
+ commitKey,
395
+ daemonDeadlineAt: Date.now() + 8_000,
396
+ hostTimeoutMs: 10_000,
397
+ });
398
+ } catch {
399
+ outcome = { status: "unknown", reason: "host_timeout" };
400
+ }
401
+ this.#broker?.updateAckPolicy(response.gate_id, {
402
+ ...ackPolicy,
403
+ state: outcome.status,
404
+ outcome,
405
+ updatedAt: new Date().toISOString(),
406
+ });
407
+ }
408
+ resolveClaim();
409
+ },
410
+ });
411
+ } catch (error) {
412
+ closeClaimInvalid(error instanceof Error ? error.message : "invalid_answer");
413
+ throw error;
414
+ }
415
+ if (resolution.status === "accepted") resolveClaim();
416
+ else closeClaimInvalid(resolution.error?.code ?? "invalid_answer");
417
+ return resolution;
418
+ }
419
+
420
+ async startRecoveryOnce(options: { participantGraceMs?: number } = {}): Promise<void> {
421
+ if (this.#disposed || !this.#brokerReady) return;
422
+ if (this.#recoveryPromise) return this.#recoveryPromise;
423
+ this.#recoveryPromise = (async () => {
424
+ if (!this.#recoveryParticipant) {
425
+ const grace = options.participantGraceMs ?? 2_000;
426
+ await Promise.race([
427
+ this.#participantReady.promise,
428
+ new Promise<void>(resolve => setTimeout(resolve, grace)),
429
+ ]);
430
+ }
431
+ if (!this.#disposed) await this.#broker?.recover();
432
+ })();
433
+ return this.#recoveryPromise;
434
+ }
435
+
436
+ async #finalizeAcceptedGate(record: PersistedGate): Promise<void> {
437
+ const policy = record.ackPolicy;
438
+ if (policy?.kind === "telegram_selected_v1" && policy.state === "pending") {
439
+ let outcome: AskSelectedAckOutcome;
440
+ const participant = this.#recoveryParticipant;
441
+ if (!participant || !this.opts.sessionId) {
442
+ outcome = { status: "failed", reason: "no_participant" };
443
+ } else {
444
+ this.#broker?.updateAckPolicy(record.gate.gate_id, {
445
+ ...policy,
446
+ state: "attempt_started",
447
+ updatedAt: new Date().toISOString(),
448
+ });
449
+ try {
450
+ outcome = await participant.requestRecoveredAskSelectedAck({
451
+ sessionId: this.opts.sessionId,
452
+ actionId: policy.actionId,
453
+ commitKey: policy.commitKey,
454
+ deadlineAt: Date.now() + 8_000,
455
+ hostTimeoutMs: 10_000,
456
+ });
457
+ } catch {
458
+ outcome = { status: "unknown", reason: "host_timeout" };
459
+ }
460
+ }
461
+ this.#broker?.updateAckPolicy(record.gate.gate_id, {
462
+ ...policy,
463
+ state: outcome.status,
464
+ outcome,
465
+ updatedAt: new Date().toISOString(),
466
+ });
467
+ }
468
+ if (policy?.kind === "telegram_selected_v1" && policy.state === "attempt_started") {
469
+ this.#broker?.updateAckPolicy(record.gate.gate_id, {
470
+ ...policy,
471
+ state: "unknown",
472
+ outcome: { status: "unknown", reason: "shutdown" },
473
+ updatedAt: new Date().toISOString(),
474
+ });
475
+ }
476
+ await this.#completeGateInteractions(record.gate.gate_id);
477
+ }
478
+
479
+ dispose(reason = "session_shutdown"): void {
480
+ this.#disposed = true;
481
+ for (const gateId of this.#pending.keys()) void this.#cancelGateInteractions(gateId, reason);
482
+ this.#rejectAllPending(new Error(`unattended session disposed: ${reason}`));
483
+ }
484
+
199
485
  listPendingGates(): import("../../rpc/rpc-types").RpcWorkflowGate[] {
200
486
  const store = this.opts.store;
201
487
  if (!store) return [];
@@ -206,6 +492,7 @@ export class UnattendedSessionControlPlane implements RpcUnattendedControlPlane,
206
492
  }
207
493
 
208
494
  emitGate(input: OpenGateInput): Promise<unknown> {
495
+ if (this.#disposed) return Promise.reject(new Error("unattended session is disposed"));
209
496
  if (!this.#broker) {
210
497
  return Promise.reject(new Error("cannot emit a workflow gate before unattended mode is negotiated"));
211
498
  }
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs";
15
15
  import * as path from "node:path";
16
+ import type { AskSelectedAckOutcome } from "../../../tools";
16
17
  import type {
17
18
  RpcWorkflowGate,
18
19
  RpcWorkflowGateContext,
@@ -20,11 +21,36 @@ import type {
20
21
  RpcWorkflowGateOption,
21
22
  RpcWorkflowGateResolution,
22
23
  RpcWorkflowGateResponse,
24
+ RpcWorkflowGateValidationError,
23
25
  RpcWorkflowStage,
24
26
  } from "../../rpc/rpc-types";
25
27
  import { RESERVED_WORKFLOW_STAGES } from "../../rpc/rpc-types";
28
+ import { classifyAskGateDisposition } from "./deep-interview-gate";
26
29
  import { answerHashOf, canonicalJson, compileGateSchema, schemaHash, validateGateAnswer } from "./workflow-gate-schema";
27
30
 
31
+ export type PersistedSemanticDisposition = "commit" | "resolve_without_commit";
32
+ export type PersistedResolutionOrigin =
33
+ | { kind: "generic"; channel: "rpc" | "bridge" | "other" }
34
+ | { kind: "telegram_notification"; interactionActionId: string }
35
+ | { kind: "legacy_unknown" };
36
+ export type PersistedAckPolicy =
37
+ | { kind: "none"; reason: "non_telegram" | "semantic_noncommit" | "legacy_unproven" }
38
+ | {
39
+ kind: "telegram_selected_v1";
40
+ commitKey: string;
41
+ actionId: string;
42
+ state: "pending" | "attempt_started" | "delivered" | "failed" | "unknown";
43
+ outcome?: AskSelectedAckOutcome;
44
+ updatedAt: string;
45
+ };
46
+
47
+ export interface GateResolutionOptions {
48
+ semanticDisposition?: PersistedSemanticDisposition;
49
+ resolutionOrigin?: PersistedResolutionOrigin;
50
+ ackPolicy?: PersistedAckPolicy;
51
+ beforeAdvance?: () => Promise<void>;
52
+ }
53
+
28
54
  const V1_STAGES: readonly RpcWorkflowStage[] = ["deep-interview", "ralplan", "ultragoal"];
29
55
 
30
56
  export interface PersistedGate {
@@ -36,6 +62,9 @@ export interface PersistedGate {
36
62
  answer?: unknown;
37
63
  resolution?: RpcWorkflowGateResolution;
38
64
  advanced: boolean;
65
+ semanticDisposition?: PersistedSemanticDisposition;
66
+ resolutionOrigin?: PersistedResolutionOrigin;
67
+ ackPolicy?: PersistedAckPolicy;
39
68
  }
40
69
 
41
70
  export interface GateStore {
@@ -153,6 +182,9 @@ export interface BrokerHooks {
153
182
  * it for any gate left `accepted` but not `advanced` by a crash.
154
183
  */
155
184
  advance?(gate: RpcWorkflowGate, answer: unknown): void | Promise<void>;
185
+ /** Runs after durable acceptance and before advance, including crash recovery. */
186
+ finalizeAccepted?(record: PersistedGate): Promise<void>;
187
+
156
188
  /** Append-only audit sink. */
157
189
  audit?(event: GateAuditEvent): void;
158
190
  }
@@ -176,12 +208,51 @@ export class WorkflowGateBrokerError extends Error {
176
208
  }
177
209
 
178
210
  export class WorkflowGateBroker {
211
+ private readonly recovering = new Set<string>();
212
+ private readonly gateLocks = new Map<string, Promise<void>>();
213
+
214
+ private async withGateLock<T>(gateId: string, operation: () => Promise<T>): Promise<T> {
215
+ const previous = this.gateLocks.get(gateId) ?? Promise.resolve();
216
+ const release = Promise.withResolvers<void>();
217
+ const current = previous.then(() => release.promise);
218
+ this.gateLocks.set(gateId, current);
219
+ await previous;
220
+ try {
221
+ return await operation();
222
+ } finally {
223
+ release.resolve();
224
+ if (this.gateLocks.get(gateId) === current) this.gateLocks.delete(gateId);
225
+ }
226
+ }
227
+
179
228
  constructor(
180
229
  private readonly runId: string,
181
230
  private readonly store: GateStore,
182
231
  private readonly hooks: BrokerHooks = {},
183
232
  ) {}
184
233
 
234
+ classifyDisposition(gateId: string, answer: unknown): PersistedSemanticDisposition {
235
+ const record = this.store.get(gateId);
236
+ if (!record) throw new WorkflowGateBrokerError("unknown_gate", `no pending gate ${gateId}`);
237
+ return classifyAskGateDisposition(record.gate, answer);
238
+ }
239
+ validationError(gateId: string, keyword: string, message: string): RpcWorkflowGateValidationError {
240
+ const record = this.store.get(gateId);
241
+ return {
242
+ code: "invalid_workflow_gate_answer",
243
+ gate_id: gateId,
244
+ schema_hash: record ? schemaHash(record.gate.schema) : "",
245
+ errors: [{ path: "$", keyword, message }],
246
+ };
247
+ }
248
+
249
+ updateAckPolicy(gateId: string, ackPolicy: PersistedAckPolicy): void {
250
+ const record = this.store.get(gateId);
251
+ if (record?.status !== "accepted")
252
+ throw new WorkflowGateBrokerError("unknown_gate", `no accepted gate ${gateId}`);
253
+ this.store.put({ ...record, ackPolicy });
254
+ }
255
+
185
256
  private runShort(): string {
186
257
  return this.runId.replace(/[^a-zA-Z0-9]/g, "").slice(-8) || "run";
187
258
  }
@@ -222,7 +293,17 @@ export class WorkflowGateBroker {
222
293
  * On success the resolution is persisted BEFORE `advance` is invoked exactly
223
294
  * once. Invalid answers leave the gate pending (per #315 acceptance).
224
295
  */
225
- async resolve(response: RpcWorkflowGateResponse): Promise<RpcWorkflowGateResolution> {
296
+ async resolve(
297
+ response: RpcWorkflowGateResponse,
298
+ options: GateResolutionOptions = {},
299
+ ): Promise<RpcWorkflowGateResolution> {
300
+ return this.withGateLock(response.gate_id, () => this.resolveUnlocked(response, options));
301
+ }
302
+
303
+ private async resolveUnlocked(
304
+ response: RpcWorkflowGateResponse,
305
+ options: GateResolutionOptions = {},
306
+ ): Promise<RpcWorkflowGateResolution> {
226
307
  const record = this.store.get(response.gate_id);
227
308
  if (!record) {
228
309
  this.hooks.audit?.({ event: "gate_response_unknown_gate", gate_id: response.gate_id });
@@ -263,6 +344,13 @@ export class WorkflowGateBroker {
263
344
  };
264
345
  }
265
346
 
347
+ const semanticDisposition =
348
+ options.semanticDisposition ?? classifyAskGateDisposition(record.gate, response.answer);
349
+ const resolutionOrigin = options.resolutionOrigin ?? { kind: "generic", channel: "rpc" as const };
350
+ const ackPolicy =
351
+ options.ackPolicy ??
352
+ ({ kind: "none", reason: semanticDisposition === "commit" ? "non_telegram" : "semantic_noncommit" } as const);
353
+
266
354
  const resolution: RpcWorkflowGateResolution = {
267
355
  gate_id: response.gate_id,
268
356
  status: "accepted",
@@ -280,18 +368,30 @@ export class WorkflowGateBroker {
280
368
  answer: response.answer,
281
369
  resolution,
282
370
  advanced: false,
371
+ semanticDisposition,
372
+ resolutionOrigin,
373
+ ackPolicy,
283
374
  });
284
375
  this.hooks.audit?.({ event: "gate_response_accepted", gate_id: response.gate_id, answer_hash: answerHash });
285
- await this.hooks.advance?.(record.gate, response.answer);
286
- this.store.put({
287
- gate: record.gate,
288
- status: "accepted",
289
- idempotencyKey: response.idempotency_key,
290
- responseHash,
291
- answer: response.answer,
292
- resolution,
293
- advanced: true,
294
- });
376
+ await options.beforeAdvance?.();
377
+ await this.hooks.finalizeAccepted?.(this.store.get(response.gate_id) as PersistedGate);
378
+ const finalized = this.store.get(response.gate_id);
379
+ if (finalized?.status !== "accepted") {
380
+ throw new WorkflowGateBrokerError(
381
+ "unknown_gate",
382
+ `accepted gate ${response.gate_id} disappeared before advance`,
383
+ );
384
+ }
385
+ if (finalized.advanced) return finalized.resolution ?? resolution;
386
+ await this.hooks.advance?.(finalized.gate, finalized.answer);
387
+ const latest = this.store.get(response.gate_id);
388
+ if (latest?.status !== "accepted") {
389
+ throw new WorkflowGateBrokerError(
390
+ "unknown_gate",
391
+ `accepted gate ${response.gate_id} disappeared after advance`,
392
+ );
393
+ }
394
+ this.store.put({ ...latest, advanced: true });
295
395
  return resolution;
296
396
  }
297
397
 
@@ -304,15 +404,25 @@ export class WorkflowGateBroker {
304
404
  async recover(): Promise<string[]> {
305
405
  const recovered: string[] = [];
306
406
  for (const listed of this.store.list()) {
307
- if (listed.status !== "accepted" || listed.advanced) continue;
308
- // Re-read immediately before advancing so a concurrent recoverer that
309
- // already advanced this gate (stale snapshot) cannot double-advance.
310
- const rec = this.store.get(listed.gate.gate_id);
311
- if (rec?.status !== "accepted" || rec.advanced) continue;
312
- await this.hooks.advance?.(rec.gate, rec.answer);
313
- this.store.put({ ...rec, advanced: true });
314
- this.hooks.audit?.({ event: "gate_advance_recovered", gate_id: rec.gate.gate_id });
315
- recovered.push(rec.gate.gate_id);
407
+ if (listed.status !== "accepted" || listed.advanced || this.recovering.has(listed.gate.gate_id)) continue;
408
+ this.recovering.add(listed.gate.gate_id);
409
+ try {
410
+ await this.withGateLock(listed.gate.gate_id, async () => {
411
+ const rec = this.store.get(listed.gate.gate_id);
412
+ if (rec?.status !== "accepted" || rec.advanced) return;
413
+ await this.hooks.finalizeAccepted?.(rec);
414
+ const finalized = this.store.get(listed.gate.gate_id);
415
+ if (finalized?.status !== "accepted" || finalized.advanced) return;
416
+ await this.hooks.advance?.(finalized.gate, finalized.answer);
417
+ const latest = this.store.get(listed.gate.gate_id);
418
+ if (latest?.status !== "accepted" || latest.advanced) return;
419
+ this.store.put({ ...latest, advanced: true });
420
+ this.hooks.audit?.({ event: "gate_advance_recovered", gate_id: latest.gate.gate_id });
421
+ recovered.push(latest.gate.gate_id);
422
+ });
423
+ } finally {
424
+ this.recovering.delete(listed.gate.gate_id);
425
+ }
316
426
  }
317
427
  return recovered;
318
428
  }