@gajae-code/coding-agent 0.16.4 → 0.16.6

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 (198) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/types/capability/types.d.ts +4 -0
  3. package/dist/types/cli/customize-doctor.d.ts +22 -0
  4. package/dist/types/config/file-lock.d.ts +31 -3
  5. package/dist/types/coordinator-mcp/question-state.d.ts +7 -0
  6. package/dist/types/coordinator-mcp/server.d.ts +7 -0
  7. package/dist/types/discovery/helpers.d.ts +33 -0
  8. package/dist/types/exec/bash-executor.d.ts +17 -1
  9. package/dist/types/exec/bash-shell-guardian.d.ts +15 -0
  10. package/dist/types/exec/bash-shell-runtime-entry.d.ts +2 -0
  11. package/dist/types/exec/bash-shell-signals.d.ts +2 -0
  12. package/dist/types/exec/bash-shell-supervisor-entry.d.ts +1 -0
  13. package/dist/types/exec/bash-shell-supervisor.d.ts +10 -0
  14. package/dist/types/exec/bash-shell-worker-entry.d.ts +2 -0
  15. package/dist/types/exec/bash-shell-worker-protocol.d.ts +63 -0
  16. package/dist/types/exec/bash-shell-worker.d.ts +1 -0
  17. package/dist/types/exec/isolated-shell.d.ts +19 -0
  18. package/dist/types/extensibility/extensions/function-hooks-internal.d.ts +9 -0
  19. package/dist/types/extensibility/extensions/function-hooks.d.ts +169 -0
  20. package/dist/types/extensibility/extensions/index.d.ts +1 -0
  21. package/dist/types/extensibility/extensions/runner.d.ts +46 -6
  22. package/dist/types/extensibility/extensions/types.d.ts +16 -0
  23. package/dist/types/extensibility/extensions/wrapper.d.ts +3 -1
  24. package/dist/types/extensibility/gjc-plugins/constrained-hooks.d.ts +35 -2
  25. package/dist/types/extensibility/gjc-plugins/types.d.ts +25 -0
  26. package/dist/types/extensibility/hooks/types.d.ts +4 -0
  27. package/dist/types/extensibility/runtime-skill-discovery.d.ts +4 -1
  28. package/dist/types/extensibility/skill-management.d.ts +9 -4
  29. package/dist/types/extensibility/skills.d.ts +2 -0
  30. package/dist/types/gjc-runtime/empty-delete-gc.d.ts +4 -1
  31. package/dist/types/gjc-runtime/linux-proc.d.ts +2 -0
  32. package/dist/types/gjc-runtime/session-state-lock.d.ts +12 -0
  33. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +41 -3
  34. package/dist/types/gjc-runtime/ultragoal-succession.d.ts +251 -0
  35. package/dist/types/modes/components/bash-execution.d.ts +1 -0
  36. package/dist/types/modes/components/execution-shared.d.ts +1 -0
  37. package/dist/types/modes/interactive-mode.d.ts +2 -0
  38. package/dist/types/modes/types.d.ts +6 -0
  39. package/dist/types/sdk/broker/ensure.d.ts +32 -0
  40. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +0 -1
  41. package/dist/types/sdk/broker/lifecycle.d.ts +6 -0
  42. package/dist/types/sdk/broker/startup-failure.d.ts +4 -1
  43. package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -0
  44. package/dist/types/sdk/bus/chat-effect-journal.d.ts +17 -2
  45. package/dist/types/sdk/bus/conversation-store.d.ts +11 -0
  46. package/dist/types/sdk/bus/lifecycle-commands.d.ts +8 -8
  47. package/dist/types/sdk/bus/slack-daemon.d.ts +1 -1
  48. package/dist/types/sdk/bus/topic-registry.d.ts +8 -0
  49. package/dist/types/sdk/client/client.d.ts +6 -0
  50. package/dist/types/sdk/host/host.d.ts +3 -1
  51. package/dist/types/sdk/host/session-runtime.d.ts +4 -2
  52. package/dist/types/sdk/lifecycle/service.d.ts +1 -0
  53. package/dist/types/sdk/router/session-router.d.ts +10 -2
  54. package/dist/types/sdk/session.d.ts +5 -1
  55. package/dist/types/session/agent-session.d.ts +2 -0
  56. package/dist/types/session/fallback-chain-controller.d.ts +15 -0
  57. package/dist/types/session/internal/managed-session-storage.d.ts +7 -0
  58. package/dist/types/session/messages.d.ts +1 -0
  59. package/dist/types/session/session-manager.d.ts +3 -1
  60. package/dist/types/setup/paseo/announce.d.ts +4 -3
  61. package/dist/types/skill-state/canonical-skills.d.ts +4 -0
  62. package/dist/types/system-prompt.d.ts +8 -0
  63. package/dist/types/task/executor.d.ts +3 -0
  64. package/dist/types/tools/bash.d.ts +1 -0
  65. package/dist/types/tools/github-cache.d.ts +2 -1
  66. package/dist/types/tools/index.d.ts +2 -0
  67. package/package.json +9 -7
  68. package/scripts/generate-sdk-operation-inventory.ts +2 -0
  69. package/src/capability/index.ts +14 -2
  70. package/src/capability/types.ts +4 -0
  71. package/src/cli/customize-doctor.ts +155 -12
  72. package/src/cli.ts +22 -0
  73. package/src/commands/sdk.ts +74 -31
  74. package/src/config/file-lock-gc.ts +40 -2
  75. package/src/config/file-lock.ts +198 -14
  76. package/src/config/model-preset-registry.ts +115 -16
  77. package/src/config/model-registry.ts +16 -12
  78. package/src/coordinator-mcp/question-state.ts +33 -2
  79. package/src/coordinator-mcp/server.ts +477 -101
  80. package/src/crash/sanitize.ts +1 -1
  81. package/src/defaults/gjc/skills/ultragoal/SKILL.md +22 -0
  82. package/src/discovery/builtin.ts +50 -62
  83. package/src/discovery/helpers.ts +297 -32
  84. package/src/exec/bash-executor.ts +330 -50
  85. package/src/exec/bash-shell-guardian.ts +388 -0
  86. package/src/exec/bash-shell-runtime-entry.ts +5 -0
  87. package/src/exec/bash-shell-signals.ts +29 -0
  88. package/src/exec/bash-shell-supervisor-entry.ts +3 -0
  89. package/src/exec/bash-shell-supervisor.ts +359 -0
  90. package/src/exec/bash-shell-worker-entry.ts +5 -0
  91. package/src/exec/bash-shell-worker-protocol.ts +36 -0
  92. package/src/exec/bash-shell-worker.ts +116 -0
  93. package/src/exec/isolated-shell.ts +393 -0
  94. package/src/extensibility/extensions/function-hooks-internal.ts +73 -0
  95. package/src/extensibility/extensions/function-hooks.ts +959 -0
  96. package/src/extensibility/extensions/index.ts +22 -0
  97. package/src/extensibility/extensions/loader.ts +53 -2
  98. package/src/extensibility/extensions/runner.ts +895 -264
  99. package/src/extensibility/extensions/types.ts +42 -0
  100. package/src/extensibility/extensions/wrapper.ts +56 -15
  101. package/src/extensibility/gjc-plugins/compiler.ts +20 -2
  102. package/src/extensibility/gjc-plugins/constrained-hooks.ts +321 -23
  103. package/src/extensibility/gjc-plugins/registry.ts +113 -1
  104. package/src/extensibility/gjc-plugins/runtime-quarantine.ts +20 -2
  105. package/src/extensibility/gjc-plugins/schema.ts +121 -4
  106. package/src/extensibility/gjc-plugins/types.ts +25 -0
  107. package/src/extensibility/hooks/loader.ts +133 -16
  108. package/src/extensibility/hooks/types.ts +28 -0
  109. package/src/extensibility/runtime-skill-discovery.ts +80 -47
  110. package/src/extensibility/skill-management.ts +200 -52
  111. package/src/extensibility/skills.ts +29 -9
  112. package/src/gjc-runtime/empty-delete-gc.ts +94 -4
  113. package/src/gjc-runtime/gc-runtime.ts +29 -0
  114. package/src/gjc-runtime/linux-proc.ts +14 -0
  115. package/src/gjc-runtime/session-state-lock.ts +51 -8
  116. package/src/gjc-runtime/session-state-sidecar.ts +27 -11
  117. package/src/gjc-runtime/state-writer.ts +30 -29
  118. package/src/gjc-runtime/ultragoal-runtime.ts +189 -47
  119. package/src/gjc-runtime/ultragoal-succession.ts +1667 -0
  120. package/src/gjc-runtime/workflow-manifest.generated.json +52 -1
  121. package/src/gjc-runtime/workflow-manifest.ts +16 -0
  122. package/src/harness-control-plane/session-lease.ts +2 -2
  123. package/src/internal-urls/docs-index.generated.ts +6 -6
  124. package/src/lsp/config.ts +10 -3
  125. package/src/lsp/index.ts +1 -1
  126. package/src/lsp/path-trust.ts +69 -14
  127. package/src/main.ts +6 -1
  128. package/src/modes/acp/acp-agent.ts +16 -3
  129. package/src/modes/components/bash-execution.ts +4 -1
  130. package/src/modes/components/execution-shared.ts +4 -1
  131. package/src/modes/components/session-observer-overlay.ts +1 -1
  132. package/src/modes/components/tool-status-header.ts +3 -1
  133. package/src/modes/controllers/command-controller.ts +1 -0
  134. package/src/modes/controllers/event-controller.ts +3 -2
  135. package/src/modes/controllers/extension-ui-controller.ts +1 -0
  136. package/src/modes/controllers/input-controller.ts +6 -3
  137. package/src/modes/interactive-mode.ts +6 -5
  138. package/src/modes/print-mode.ts +5 -1
  139. package/src/modes/prompt-action-autocomplete.ts +54 -27
  140. package/src/modes/runtime-init.ts +2 -0
  141. package/src/modes/types.ts +16 -3
  142. package/src/modes/utils/ui-helpers.ts +1 -0
  143. package/src/prompts/tools/subagent.md +1 -0
  144. package/src/runtime-mcp/manager.ts +20 -2
  145. package/src/runtime-mcp/pool.test.ts +20 -11
  146. package/src/sdk/broker/broker.ts +119 -28
  147. package/src/sdk/broker/ensure.ts +248 -41
  148. package/src/sdk/broker/lifecycle-ledger.ts +7 -17
  149. package/src/sdk/broker/lifecycle.ts +56 -6
  150. package/src/sdk/broker/startup-failure.ts +15 -6
  151. package/src/sdk/bus/chat-daemon-control.ts +3 -1
  152. package/src/sdk/bus/chat-daemon-runtime.ts +35 -5
  153. package/src/sdk/bus/chat-effect-journal.ts +132 -7
  154. package/src/sdk/bus/conversation-store.ts +29 -0
  155. package/src/sdk/bus/lifecycle-commands.ts +17 -11
  156. package/src/sdk/bus/slack-conversation.ts +1 -0
  157. package/src/sdk/bus/slack-daemon.ts +387 -48
  158. package/src/sdk/bus/telegram-daemon.ts +16 -2
  159. package/src/sdk/bus/topic-registry.ts +19 -0
  160. package/src/sdk/cli/master-cli.ts +1 -1
  161. package/src/sdk/cli/session-cli.ts +2 -2
  162. package/src/sdk/client/client.ts +66 -17
  163. package/src/sdk/host/host.ts +29 -9
  164. package/src/sdk/host/query/revision-store.ts +19 -13
  165. package/src/sdk/host/reverse-leases.ts +7 -4
  166. package/src/sdk/host/session-runtime.test.ts +50 -8
  167. package/src/sdk/host/session-runtime.ts +158 -51
  168. package/src/sdk/lifecycle/service.ts +4 -0
  169. package/src/sdk/protocol/operation-inventory.generated.json +11 -0
  170. package/src/sdk/router/session-router.ts +89 -38
  171. package/src/sdk/session.ts +155 -26
  172. package/src/sdk/transport/relay.ts +35 -13
  173. package/src/session/agent-session.ts +117 -52
  174. package/src/session/fallback-chain-controller.ts +38 -0
  175. package/src/session/internal/managed-session-storage.ts +19 -5
  176. package/src/session/messages.ts +5 -0
  177. package/src/session/session-manager.ts +29 -5
  178. package/src/session-import/redact.ts +5 -1
  179. package/src/setup/paseo/announce.ts +49 -43
  180. package/src/setup/paseo/setup-deps.ts +1 -1
  181. package/src/skill-state/canonical-skills.ts +12 -0
  182. package/src/skills/skills.test.ts +41 -21
  183. package/src/system-prompt.ts +25 -3
  184. package/src/task/executor.ts +5 -0
  185. package/src/task/index.ts +4 -0
  186. package/src/tools/bash.ts +28 -11
  187. package/src/tools/gh.ts +26 -3
  188. package/src/tools/github-cache.ts +68 -16
  189. package/src/tools/index.ts +2 -0
  190. package/src/tools/job.ts +13 -2
  191. package/src/tools/read.ts +14 -2
  192. package/src/tools/render-utils.ts +1 -3
  193. package/src/tools/resource-gc.ts +28 -8
  194. package/src/tools/skill-discovery.ts +5 -0
  195. package/src/tools/skill.ts +15 -0
  196. package/src/tools/subagent.ts +3 -2
  197. package/src/tools/tool-catalog.generated.ts +2 -2
  198. package/src/tools/ultragoal-ask-guard.ts +2 -2
@@ -1,8 +1,9 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
+ import { processIncarnation } from "./process-incarnation";
3
4
 
4
5
  export interface BrokerStartupFailureMarker {
5
- version: 1;
6
+ version: 2;
6
7
  reason: string;
7
8
  exitCode: number | null;
8
9
  signal: string | null;
@@ -16,6 +17,8 @@ export interface BrokerStartupFailureMarker {
16
17
  * failure.
17
18
  */
18
19
  pid: number;
20
+ /** Kernel-derived identity for the exact process incarnation that wrote the marker. */
21
+ incarnation: string;
19
22
  }
20
23
 
21
24
  const BROKER_STARTUP_FAILURE_FILE = "broker.startup-failure.json";
@@ -30,14 +33,16 @@ function boundedMarker(
30
33
  exitCode: number | null,
31
34
  signal: string | null,
32
35
  pid: number,
36
+ incarnation: string,
33
37
  ): BrokerStartupFailureMarker {
34
38
  return {
35
- version: 1,
39
+ version: 2,
36
40
  reason: reason.slice(0, MAX_BROKER_STARTUP_FAILURE_REASON),
37
41
  exitCode,
38
42
  signal,
39
43
  writtenAt: Date.now(),
40
44
  pid,
45
+ incarnation,
41
46
  };
42
47
  }
43
48
 
@@ -49,13 +54,15 @@ function boundedMarker(
49
54
  */
50
55
  export async function writeBrokerStartupFailureMarker(
51
56
  agentDir: string,
52
- failure: { reason: string; exitCode: number | null; signal: string | null; pid: number },
57
+ failure: { reason: string; exitCode: number | null; signal: string | null; pid: number; incarnation?: string },
53
58
  ): Promise<void> {
54
59
  try {
60
+ const incarnation = failure.incarnation ?? processIncarnation(failure.pid);
61
+ if (!incarnation) return;
55
62
  await fs.mkdir(path.dirname(brokerStartupFailurePath(agentDir)), { recursive: true, mode: 0o700 });
56
63
  await Bun.write(
57
64
  brokerStartupFailurePath(agentDir),
58
- JSON.stringify(boundedMarker(failure.reason, failure.exitCode, failure.signal, failure.pid)),
65
+ JSON.stringify(boundedMarker(failure.reason, failure.exitCode, failure.signal, failure.pid, incarnation)),
59
66
  );
60
67
  } catch {
61
68
  // Best-effort only.
@@ -74,7 +81,7 @@ export async function readBrokerStartupFailureMarker(
74
81
  if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
75
82
  const marker = value as Partial<BrokerStartupFailureMarker>;
76
83
  if (
77
- marker.version !== 1 ||
84
+ marker.version !== 2 ||
78
85
  typeof marker.reason !== "string" ||
79
86
  marker.reason.length === 0 ||
80
87
  (marker.exitCode !== null && typeof marker.exitCode !== "number") ||
@@ -82,7 +89,9 @@ export async function readBrokerStartupFailureMarker(
82
89
  typeof marker.writtenAt !== "number" ||
83
90
  typeof marker.pid !== "number" ||
84
91
  !Number.isInteger(marker.pid) ||
85
- marker.pid <= 0
92
+ marker.pid <= 0 ||
93
+ typeof marker.incarnation !== "string" ||
94
+ marker.incarnation.length === 0
86
95
  )
87
96
  return undefined;
88
97
  return marker as BrokerStartupFailureMarker;
@@ -133,10 +133,12 @@ export type ChatDaemonAction = "stop" | "reload";
133
133
  * Discord generation 72 / Slack generation 75 serialize same-generation
134
134
  * successor attachment admission behind predecessor provider retirement, so a
135
135
  * pre-upgrade owner cannot retain the queue race fixed by #5120.
136
+ * Slack generation 80 persists and fences the inbound SDK dispatch boundary so
137
+ * crashes, recovery, and attachment retirement cannot replay ambiguous work.
136
138
  */
137
139
  export const CHAT_DAEMON_GENERATIONS: Readonly<Record<ChatDaemonKind, number>> = {
138
140
  discord: 73,
139
- slack: 79,
141
+ slack: 80,
140
142
  };
141
143
 
142
144
  export function chatDaemonGeneration(kind: ChatDaemonKind): number {
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { logger } from "@gajae-code/utils";
3
- import { SdkClientError } from "../client/client";
3
+ import { SdkClientError, SdkPreparedDispatchError } from "../client/client";
4
4
  import { SESSION_PREPARED_EVENT } from "../host/host";
5
5
 
6
6
  import type { SessionRouterDeps } from "../router";
@@ -50,6 +50,7 @@ function chatDeliveryPhase(error: unknown): ChatDeliveryPhase | undefined {
50
50
  if (error instanceof SessionRouterError) return error.phase;
51
51
 
52
52
  if (error instanceof ChatDeliveryError) return error.phase;
53
+ if (error instanceof SdkPreparedDispatchError) return "pre_send";
53
54
  if (!(error instanceof SdkClientError)) return undefined;
54
55
  // `uncertain_after_send` is the one code that states outright that the frame
55
56
  // reached the host: reporting it as a definite failure would tell the operator a
@@ -414,8 +415,16 @@ export class ChatDaemonRuntime {
414
415
  authorizeActor: async actorId => config.authorizedUserId === actorId,
415
416
  resolveAttachment: async sessionId => this.#router.attachment(sessionId),
416
417
  resolveBindingAuthority: async sessionId => await this.#router.bindingAuthority(sessionId),
417
- onCommand: async (sessionId, content, attachment, idempotencyKey) =>
418
- await this.#runChatCommand("slack", sessionId, content, attachment, idempotencyKey),
418
+ onCommand: async (sessionId, content, attachment, idempotencyKey, beforeDispatch, dispatchFence) =>
419
+ await this.#runChatCommand(
420
+ "slack",
421
+ sessionId,
422
+ content,
423
+ attachment,
424
+ idempotencyKey,
425
+ beforeDispatch,
426
+ dispatchFence,
427
+ ),
419
428
  });
420
429
  }
421
430
  try {
@@ -730,6 +739,8 @@ export class ChatDaemonRuntime {
730
739
  content: string,
731
740
  expectedAttachment: SessionAttachment,
732
741
  idempotencyKey: string = randomUUID(),
742
+ beforeDispatch?: () => void,
743
+ dispatchFence?: <T>(dispatch: () => Promise<T>) => Promise<T>,
733
744
  ): Promise<boolean> {
734
745
  const match = /^\/sdk\s+(control|query|global)\s+([^\s]+)(?:\s+(.+))?\s*$/.exec(content);
735
746
  if (!match) return false;
@@ -748,7 +759,13 @@ export class ChatDaemonRuntime {
748
759
  try {
749
760
  outcome = await sendAuthorizedChatOperation(transport, { kind, operation, input }, async () => {
750
761
  if (kind === "global")
751
- return await this.#runGlobalCommand(operation, input as Record<string, unknown>, idempotencyKey);
762
+ return await this.#runGlobalCommand(
763
+ operation,
764
+ input as Record<string, unknown>,
765
+ idempotencyKey,
766
+ beforeDispatch,
767
+ dispatchFence,
768
+ );
752
769
  if (!expectedAttachment.isCurrent()) throw new ChatDeliveryError("pre_send");
753
770
  return await this.#router.request(
754
771
  sessionId,
@@ -757,6 +774,12 @@ export class ChatDaemonRuntime {
757
774
  : { type: "query_request", query: operation, input, idempotencyKey },
758
775
  expectedAttachment.generation,
759
776
  expectedAttachment,
777
+ beforeDispatch || dispatchFence
778
+ ? {
779
+ beforeDispatch,
780
+ dispatchFence: dispatchFence ? dispatch => dispatchFence(dispatch) : undefined,
781
+ }
782
+ : undefined,
760
783
  );
761
784
  });
762
785
  } catch (error) {
@@ -776,10 +799,17 @@ export class ChatDaemonRuntime {
776
799
  operation: string,
777
800
  input: Record<string, unknown>,
778
801
  idempotencyKey: string,
802
+ beforeDispatch?: () => void,
803
+ dispatchFence?: <T>(dispatch: () => Promise<T>) => Promise<T>,
779
804
  ): Promise<Record<string, unknown>> {
780
805
  if (operation !== "session.list") throw new ChatDeliveryError("pre_send");
781
806
  try {
782
- return await this.#router.listBrokerSessions(input, idempotencyKey);
807
+ return await this.#router.listBrokerSessions(
808
+ input,
809
+ idempotencyKey,
810
+ { beforeDispatch },
811
+ dispatchFence ? dispatch => dispatchFence(dispatch) : undefined,
812
+ );
783
813
  } catch (error) {
784
814
  if (error instanceof SessionRouterError) throw new ChatDeliveryError(error.phase);
785
815
  throw error;
@@ -14,7 +14,7 @@ export function chatEffectJournalPath(agentDir: string, transport: "discord" | "
14
14
  return conversationStorePath(agentDir, transport, "effects.json");
15
15
  }
16
16
 
17
- export type ChatEffectState = "pending" | "leased" | "accepted" | "uncertain" | "terminal";
17
+ export type ChatEffectState = "pending" | "leased" | "dispatching" | "accepted" | "uncertain" | "terminal";
18
18
 
19
19
  /** Provider receipts are identifiers/status only. Never put request or response bodies here. */
20
20
  export interface ChatEffectReceipt {
@@ -262,7 +262,11 @@ export class ChatEffectJournal {
262
262
  let renewed: ChatEffect<TPayload> | undefined;
263
263
  const now = this.#now();
264
264
  await this.#store.transact(id, current => {
265
- if (current?.state !== "leased" || current.owner !== lease.owner || current.epoch !== lease.epoch)
265
+ if (
266
+ (current?.state !== "leased" && current?.state !== "dispatching") ||
267
+ current.owner !== lease.owner ||
268
+ current.epoch !== lease.epoch
269
+ )
266
270
  return current;
267
271
  renewed = {
268
272
  ...current,
@@ -275,6 +279,78 @@ export class ChatEffectJournal {
275
279
  return renewed;
276
280
  }
277
281
 
282
+ async markDispatching<TPayload = unknown>(
283
+ id: string,
284
+ lease: ChatEffectLease,
285
+ ): Promise<ChatEffect<TPayload> | undefined> {
286
+ let marked: ChatEffect<TPayload> | undefined;
287
+ const now = this.#now();
288
+ await this.#store.transact(id, current => {
289
+ if (current?.state !== "leased" || current.owner !== lease.owner || current.epoch !== lease.epoch)
290
+ return current;
291
+ marked = {
292
+ ...current,
293
+ generation: current.generation + 1,
294
+ state: "dispatching",
295
+ updatedAt: now,
296
+ } as ChatEffect<TPayload>;
297
+ return marked;
298
+ });
299
+ return marked;
300
+ }
301
+
302
+ async ownsDispatching(id: string, lease: ChatEffectLease): Promise<boolean> {
303
+ const current = await this.#store.read(id);
304
+ return (
305
+ current?.state === "dispatching" &&
306
+ current.owner === lease.owner &&
307
+ current.epoch === lease.epoch &&
308
+ (current.leaseExpiresAt ?? 0) > this.#now()
309
+ );
310
+ }
311
+
312
+ /** Hold the durable dispatching lease until the synchronous SDK prewire boundary. */
313
+ async dispatchWithLeaseFence<R>(
314
+ id: string,
315
+ lease: ChatEffectLease,
316
+ dispatch: () => Promise<R>,
317
+ dispatched: Promise<void>,
318
+ ): Promise<{ value: R } | undefined> {
319
+ return await this.#store.dispatchWithSnapshotFence(id, current => {
320
+ if (
321
+ current?.state !== "dispatching" ||
322
+ current.owner !== lease.owner ||
323
+ current.epoch !== lease.epoch ||
324
+ (current.leaseExpiresAt ?? 0) <= this.#now()
325
+ )
326
+ return undefined;
327
+ return { result: dispatch(), dispatched };
328
+ });
329
+ }
330
+
331
+ async recoverDispatchingAsUncertain<TPayload = unknown>(
332
+ id: string,
333
+ options: { onlyIfExpired?: boolean } = {},
334
+ ): Promise<ChatEffect<TPayload> | undefined> {
335
+ let recovered: ChatEffect<TPayload> | undefined;
336
+ const now = this.#now();
337
+ await this.#store.transact(id, current => {
338
+ if (current?.state !== "dispatching") return current;
339
+ if (options.onlyIfExpired && (current.leaseExpiresAt ?? 0) > now) return current;
340
+ recovered = {
341
+ ...current,
342
+ generation: current.generation + 1,
343
+ state: "uncertain",
344
+ owner: undefined,
345
+ leaseExpiresAt: undefined,
346
+ receipt: { status: "uncertain" },
347
+ updatedAt: now,
348
+ } as ChatEffect<TPayload>;
349
+ return recovered;
350
+ });
351
+ return recovered;
352
+ }
353
+
278
354
  /** Persists provider progress without releasing the owner/epoch fence. */
279
355
  async recordReceipt<TPayload = unknown>(
280
356
  id: string,
@@ -284,7 +360,11 @@ export class ChatEffectJournal {
284
360
  let recorded: ChatEffect<TPayload> | undefined;
285
361
  const now = this.#now();
286
362
  await this.#store.transact(id, current => {
287
- if (current?.state !== "leased" || current.owner !== lease.owner || current.epoch !== lease.epoch)
363
+ if (
364
+ (current?.state !== "leased" && current?.state !== "dispatching") ||
365
+ current.owner !== lease.owner ||
366
+ current.epoch !== lease.epoch
367
+ )
288
368
  return current;
289
369
  recorded = {
290
370
  ...current,
@@ -300,13 +380,17 @@ export class ChatEffectJournal {
300
380
  async record<TPayload = unknown>(
301
381
  id: string,
302
382
  lease: ChatEffectLease,
303
- state: Exclude<ChatEffectState, "pending" | "leased">,
304
- receipt?: ChatEffectReceipt,
383
+ state: "accepted" | "uncertain" | "terminal",
384
+ receipt: ChatEffectReceipt = {},
305
385
  ): Promise<ChatEffect<TPayload> | undefined> {
306
386
  let recorded: ChatEffect<TPayload> | undefined;
307
387
  const now = this.#now();
308
388
  await this.#store.transact(id, current => {
309
- if (current?.state !== "leased" || current.owner !== lease.owner || current.epoch !== lease.epoch)
389
+ if (
390
+ (current?.state !== "leased" && current?.state !== "dispatching") ||
391
+ current.owner !== lease.owner ||
392
+ current.epoch !== lease.epoch
393
+ )
310
394
  return current;
311
395
  recorded = {
312
396
  ...current,
@@ -329,7 +413,10 @@ export class ChatEffectJournal {
329
413
  const now = this.#now();
330
414
  await this.#store.transact(id, current => {
331
415
  if (!current || current.state === "terminal") return current;
332
- if (current.state === "leased" && (!lease || current.owner !== lease.owner || current.epoch !== lease.epoch))
416
+ if (
417
+ (current.state === "leased" || current.state === "dispatching") &&
418
+ (!lease || current.owner !== lease.owner || current.epoch !== lease.epoch)
419
+ )
333
420
  return current;
334
421
 
335
422
  terminalized = {
@@ -347,6 +434,44 @@ export class ChatEffectJournal {
347
434
  return terminalized;
348
435
  }
349
436
 
437
+ /**
438
+ * Terminalize only a decided effect. The uncertainty check and state change
439
+ * share one store transaction so a concurrent sender cannot publish an
440
+ * ambiguity tombstone between a caller's read and stale cleanup.
441
+ */
442
+ async terminalizeDecided(
443
+ id: string,
444
+ receipt: ChatEffectReceipt,
445
+ lease?: ChatEffectLease,
446
+ ): Promise<ChatEffect | undefined> {
447
+ let terminalized: ChatEffect | undefined;
448
+ const now = this.#now();
449
+ await this.#store.transact(id, current => {
450
+ if (!current || current.state === "uncertain") return current;
451
+ if (current.state === "terminal") {
452
+ terminalized = current;
453
+ return current;
454
+ }
455
+ if (
456
+ (current.state === "leased" || current.state === "dispatching") &&
457
+ (!lease || current.owner !== lease.owner || current.epoch !== lease.epoch)
458
+ )
459
+ return current;
460
+ terminalized = {
461
+ ...current,
462
+ generation: current.generation + 1,
463
+ state: "terminal",
464
+ owner: undefined,
465
+ leaseExpiresAt: undefined,
466
+ receipt,
467
+ updatedAt: now,
468
+ };
469
+ return terminalized;
470
+ });
471
+ if (terminalized?.state === "terminal") await this.#pruneTerminal();
472
+ return terminalized;
473
+ }
474
+
350
475
  async #pruneTerminal(): Promise<void> {
351
476
  const terminal = (await this.list())
352
477
  .filter(effect => effect.state === "terminal")
@@ -208,6 +208,35 @@ export class ConversationStore<T extends ConversationRecord> {
208
208
  });
209
209
  }
210
210
 
211
+ /**
212
+ * Hold the cross-process mapping lock until a prepared dispatch either reaches
213
+ * its synchronous wire boundary or fails before sending. The result may remain
214
+ * pending after the lock is released while the remote response is awaited.
215
+ */
216
+ async dispatchWithSnapshotFence<R>(
217
+ key: string,
218
+ prepare: (
219
+ current: T | undefined,
220
+ conversations: Readonly<Record<string, T>>,
221
+ ) => { result: Promise<R>; dispatched: Promise<void> } | undefined,
222
+ ): Promise<{ value: R } | undefined> {
223
+ let result: Promise<R> | undefined;
224
+ await this.#withLock(async () => {
225
+ const document = await this.#readDocument();
226
+ const prepared = prepare(document.conversations[key], Object.freeze({ ...document.conversations }));
227
+ if (!prepared) return;
228
+ result = prepared.result;
229
+ await Promise.race([
230
+ prepared.dispatched,
231
+ prepared.result.then(
232
+ () => undefined,
233
+ () => undefined,
234
+ ),
235
+ ]);
236
+ });
237
+ return result ? { value: await result } : undefined;
238
+ }
239
+
211
240
  async #withLock<R>(operation: () => Promise<R>): Promise<R> {
212
241
  const previous = this.#locks.get(this.#file) ?? Promise.resolve();
213
242
  const gate = Promise.withResolvers<void>();
@@ -15,10 +15,15 @@ import * as os from "node:os";
15
15
  import type { SessionCloseTarget, SessionCreateTarget, SessionLifecycleResponse, SessionResumeTarget } from "./index";
16
16
 
17
17
  export type LifecycleCommandVerb = "session_create" | "session_close" | "session_resume";
18
- function normalizeLifecycleCommandToken(
19
- token: string,
20
- ctx: { chatType?: string; botUsername?: string } = {},
21
- ): string | undefined {
18
+
19
+ export interface LifecycleCommandContext {
20
+ chatType?: string;
21
+ botUsername?: string;
22
+ /** Session durably associated with the Telegram topic containing the command. */
23
+ threadSessionId?: string;
24
+ }
25
+
26
+ function normalizeLifecycleCommandToken(token: string, ctx: LifecycleCommandContext = {}): string | undefined {
22
27
  const at = token.indexOf("@");
23
28
  const command = at === -1 ? token : token.slice(0, at);
24
29
  if (!/^\/session_(create|close|resume|recent)\b/.test(command)) return undefined;
@@ -44,8 +49,8 @@ const USAGE = [
44
49
  "/session_create path <dir> [--mpreset <profile>]",
45
50
  "/session_create worktree <repo> <branch> [--mpreset <profile>]",
46
51
  "/session_create dir <newdir> [--mpreset <profile>]",
47
- "/session_close <sessionId>",
48
- "/session_resume <sessionId|prefix>",
52
+ "/session_close <sessionId> (ID optional inside its session thread)",
53
+ "/session_resume <sessionId|prefix> (ID optional inside its session thread)",
49
54
  "/session_recent [create|resume]",
50
55
  ].join("\n");
51
56
 
@@ -103,10 +108,7 @@ export function isLifecycleCommandLikeText(text: string | undefined): boolean {
103
108
  }
104
109
 
105
110
  /** True when the text begins an addressable /session_* command (cheap pre-gate). */
106
- export function isLifecycleCommandText(
107
- text: string | undefined,
108
- ctx: { chatType?: string; botUsername?: string } = {},
109
- ): boolean {
111
+ export function isLifecycleCommandText(text: string | undefined, ctx: LifecycleCommandContext = {}): boolean {
110
112
  if (!text) return false;
111
113
  const [rawCommand] = text.trim().split(/\s+/, 1);
112
114
  return normalizeLifecycleCommandToken(rawCommand ?? "", ctx) !== undefined;
@@ -139,7 +141,7 @@ function extractModelPreset(args: string[]): { positional: string[]; modelPreset
139
141
  */
140
142
  export function parseLifecycleCommand(
141
143
  text: string | undefined,
142
- ctx: { chatType?: string; botUsername?: string } = {},
144
+ ctx: LifecycleCommandContext = {},
143
145
  ): ParsedLifecycleCommand {
144
146
  const tokens = tokenizeLifecycleCommand((text ?? "").trim());
145
147
  if (!tokens) return { kind: "usage", message: USAGE };
@@ -166,6 +168,8 @@ export function parseLifecycleCommand(
166
168
  }
167
169
 
168
170
  if (command === "/session_close") {
171
+ if (args.length === 0 && ctx.threadSessionId)
172
+ return { kind: "close", target: { sessionId: ctx.threadSessionId } };
169
173
  if (args.length !== 1) return { kind: "usage", message: USAGE };
170
174
  const sessionId = args[0]!;
171
175
  if (!isSafeIdentifier(sessionId)) {
@@ -175,6 +179,8 @@ export function parseLifecycleCommand(
175
179
  }
176
180
 
177
181
  if (command === "/session_resume") {
182
+ if (args.length === 0 && ctx.threadSessionId)
183
+ return { kind: "resume", target: { sessionIdOrPrefix: ctx.threadSessionId } };
178
184
  if (args.length !== 1) return { kind: "usage", message: USAGE };
179
185
  const idOrPrefix = args[0]!;
180
186
  if (!isSafeIdentifier(idOrPrefix)) {
@@ -76,6 +76,7 @@ export function normalizeSlackConversation(record: SlackConversation): SlackConv
76
76
  export function acceptsSlackInbound(record: SlackConversation, rootTs: string, endpointGeneration: number): boolean {
77
77
  return (
78
78
  (record.state === "active" || record.state === "resumed_root") &&
79
+ record.cleanupEffectId === undefined &&
79
80
  record.rootTs === rootTs &&
80
81
  record.endpointGeneration === endpointGeneration
81
82
  );