@gajae-code/coding-agent 0.12.5 → 0.12.7

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 (91) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +4 -1
  3. package/dist/types/async/job-manager.d.ts +36 -1
  4. package/dist/types/extensibility/extensions/runner.d.ts +9 -7
  5. package/dist/types/gjc-runtime/launch-tmux.d.ts +22 -3
  6. package/dist/types/gjc-runtime/psmux-detect.d.ts +3 -0
  7. package/dist/types/gjc-runtime/team-launch.d.ts +3 -1
  8. package/dist/types/gjc-runtime/team-runtime.d.ts +14 -2
  9. package/dist/types/gjc-runtime/team-store.d.ts +0 -4
  10. package/dist/types/gjc-runtime/team-workers.d.ts +5 -0
  11. package/dist/types/gjc-runtime/tmux-common.d.ts +1 -0
  12. package/dist/types/gjc-runtime/tmux-provider-context.d.ts +57 -0
  13. package/dist/types/gjc-runtime/tmux-sessions.d.ts +8 -1
  14. package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +3 -0
  15. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +21 -4
  16. package/dist/types/modes/components/gjc-bundle-settings.d.ts +1 -0
  17. package/dist/types/modes/components/plugin-settings.d.ts +2 -0
  18. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  19. package/dist/types/sdk/broker/broker.d.ts +18 -0
  20. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +13 -0
  21. package/dist/types/sdk/broker/lifecycle.d.ts +1 -0
  22. package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -3
  23. package/dist/types/sdk/bus/index.d.ts +4 -0
  24. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +2 -2
  25. package/dist/types/sdk/bus/telegram-daemon.d.ts +25 -3
  26. package/dist/types/sdk/bus/topic-registry.d.ts +63 -3
  27. package/dist/types/session/agent-session.d.ts +5 -4
  28. package/dist/types/session/attempt-record-store.d.ts +19 -0
  29. package/dist/types/session/session-manager.d.ts +1 -0
  30. package/dist/types/session/session-storage.d.ts +8 -0
  31. package/dist/types/task/receipt.d.ts +1 -0
  32. package/dist/types/task/types.d.ts +43 -0
  33. package/dist/types/tools/subagent.d.ts +15 -0
  34. package/dist/types/tools/todo-write.d.ts +3 -0
  35. package/package.json +8 -8
  36. package/scripts/capture-sticky-viewport-showcase.ts +131 -5
  37. package/scripts/verify-gjc-sdk-canonicalization.ts +128 -183
  38. package/scripts/verify-sticky-viewport-showcase.ts +620 -93
  39. package/src/async/job-manager.ts +430 -46
  40. package/src/config/model-profiles.ts +8 -0
  41. package/src/config/settings-schema.ts +17 -3
  42. package/src/defaults/gjc/skills/ultragoal/SKILL.md +2 -0
  43. package/src/extensibility/extensions/runner.ts +80 -6
  44. package/src/extensibility/extensions/wrapper.ts +22 -15
  45. package/src/extensibility/runtime-skill-discovery.ts +4 -6
  46. package/src/gjc-runtime/launch-tmux.ts +309 -293
  47. package/src/gjc-runtime/managed-owner-supervisor.ts +6 -2
  48. package/src/gjc-runtime/psmux-detect.ts +52 -6
  49. package/src/gjc-runtime/team-launch.ts +50 -2
  50. package/src/gjc-runtime/team-runtime.ts +716 -251
  51. package/src/gjc-runtime/team-store.ts +25 -7
  52. package/src/gjc-runtime/team-workers.ts +161 -1
  53. package/src/gjc-runtime/tmux-common.ts +16 -4
  54. package/src/gjc-runtime/tmux-gc.ts +37 -5
  55. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -0
  56. package/src/gjc-runtime/tmux-provider-context.ts +451 -0
  57. package/src/gjc-runtime/tmux-sessions.ts +409 -156
  58. package/src/gjc-runtime/ultragoal-change-set.ts +90 -44
  59. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +7 -0
  60. package/src/gjc-runtime/ultragoal-runtime.ts +360 -156
  61. package/src/internal-urls/docs-index.generated.ts +4 -4
  62. package/src/internal-urls/local-protocol.ts +39 -18
  63. package/src/modes/acp/acp-agent.ts +27 -13
  64. package/src/modes/components/gjc-bundle-settings.ts +2 -0
  65. package/src/modes/components/plugin-settings.ts +5 -0
  66. package/src/modes/components/settings-selector.ts +4 -0
  67. package/src/modes/controllers/selector-controller.ts +1 -0
  68. package/src/prompts/tools/monitor.md +4 -4
  69. package/src/sdk/broker/broker.ts +93 -10
  70. package/src/sdk/broker/lifecycle-ledger.ts +227 -11
  71. package/src/sdk/broker/lifecycle.ts +691 -92
  72. package/src/sdk/bus/chat-daemon-control.ts +30 -6
  73. package/src/sdk/bus/index.ts +23 -6
  74. package/src/sdk/bus/telegram-daemon-contract.ts +2 -2
  75. package/src/sdk/bus/telegram-daemon.ts +283 -44
  76. package/src/sdk/bus/topic-registry.ts +133 -10
  77. package/src/sdk/session.ts +7 -6
  78. package/src/session/agent-session.ts +752 -339
  79. package/src/session/attempt-record-store.ts +106 -0
  80. package/src/session/internal/managed-session-scope.ts +252 -67
  81. package/src/session/internal/managed-session-storage.ts +1 -0
  82. package/src/session/session-manager.ts +7 -0
  83. package/src/session/session-storage.ts +146 -24
  84. package/src/task/index.ts +230 -19
  85. package/src/task/provider-retry-status.ts +4 -1
  86. package/src/task/receipt.ts +2 -0
  87. package/src/task/types.ts +10 -0
  88. package/src/tools/monitor.ts +7 -1
  89. package/src/tools/output-meta.ts +10 -2
  90. package/src/tools/subagent.ts +97 -75
  91. package/src/tools/todo-write.ts +47 -0
@@ -80,10 +80,32 @@ async function listFilesRecursively(rootPath: string): Promise<string[]> {
80
80
 
81
81
  return files.sort((a, b) => a.localeCompare(b));
82
82
  }
83
+ function localListingLabel(file: string): string {
84
+ return file.replace(/[\\[\]\r\n]/g, character => {
85
+ switch (character) {
86
+ case "\r":
87
+ return "\\r";
88
+ case "\n":
89
+ return "\\n";
90
+ default:
91
+ return `\\${character}`;
92
+ }
93
+ });
94
+ }
95
+
96
+ function localListingHref(file: string): string {
97
+ return file
98
+ .split("/")
99
+ .map(component => encodeURIComponent(component).replaceAll("(", "%28").replaceAll(")", "%29"))
100
+ .join("/");
101
+ }
83
102
 
84
103
  async function buildListing(url: InternalUrl, localRoot: string): Promise<InternalResource> {
85
104
  const files = await listFilesRecursively(localRoot);
86
- const listing = files.length === 0 ? "(empty)" : files.map(file => `- [${file}](local://${file})`).join("\n");
105
+ const listing =
106
+ files.length === 0
107
+ ? "(empty)"
108
+ : files.map(file => `- [${localListingLabel(file)}](local://${localListingHref(file)})`).join("\n");
87
109
  const content =
88
110
  `# Local\n\n` +
89
111
  `Session-scoped scratch space for large intermediate data, subagent handoffs, and reusable planning artifacts.\n\n` +
@@ -103,31 +125,30 @@ async function buildListing(url: InternalUrl, localRoot: string): Promise<Intern
103
125
  function extractRelativePath(url: InternalUrl): string {
104
126
  const host = url.rawHost || url.hostname;
105
127
  const pathname = url.rawPathname ?? url.pathname;
106
-
107
- const combined = host
108
- ? pathname && pathname !== "/"
109
- ? `${host}${pathname}`
110
- : host
111
- : pathname && pathname !== "/"
112
- ? pathname.slice(1)
113
- : "";
114
-
115
- if (!combined) {
116
- return "";
117
- }
118
-
119
- let decoded: string;
128
+ const encodedAuthority = /^local:\/\/([^/?#]*)/i.exec(url.href)?.[1] ?? "";
129
+ const validatedHost = host;
130
+ let decodedPathname = pathname;
120
131
  try {
121
- decoded = decodeURIComponent(combined.replaceAll("\\", "/"));
132
+ const decodedAuthority = decodeURIComponent(encodedAuthority);
133
+ if (host && decodedAuthority !== host) throw new Error("authority_mismatch");
134
+ decodedPathname = decodeURIComponent(pathname.replaceAll("\\", "/"));
122
135
  } catch {
123
136
  throw new Error(`Invalid URL encoding in local:// path: ${url.href}`);
124
137
  }
138
+ const combined = validatedHost
139
+ ? decodedPathname && decodedPathname !== "/"
140
+ ? `${validatedHost}${decodedPathname}`
141
+ : validatedHost
142
+ : decodedPathname && decodedPathname !== "/"
143
+ ? decodedPathname.slice(1)
144
+ : "";
145
+ if (!combined) return "";
125
146
  try {
126
- validateRelativePath(decoded);
147
+ validateRelativePath(combined);
127
148
  } catch (error) {
128
149
  throw toLocalValidationError(error);
129
150
  }
130
- return decoded;
151
+ return combined;
131
152
  }
132
153
 
133
154
  function safeSessionId(options: LocalProtocolOptions): string {
@@ -718,6 +718,7 @@ export class AcpAgent implements Agent {
718
718
  readonly #resolvingExisting = new Map<string, PendingAttachment>();
719
719
  readonly #knownSessionCwds = new Map<string, string>();
720
720
  readonly #knownSessionMetadata = new Map<string, { title?: string; updatedAt?: string }>();
721
+ readonly #pendingDeleteLocators = new Map<string, { cwd: string; path: string }>();
721
722
  readonly #pendingCloseIdempotencyKeys = new Map<string, string>();
722
723
  readonly #sessionEpochs = new Map<string, number>();
723
724
  readonly #tearingDown = new Map<string, number>();
@@ -918,24 +919,35 @@ export class AcpAgent implements Agent {
918
919
 
919
920
  async deleteSession(params: DeleteSessionRequest): Promise<DeleteSessionResponse> {
920
921
  const record = this.#sessions.get(params.sessionId);
921
- const cwd = record?.cwd ?? this.#knownSessionCwds.get(params.sessionId);
922
- // ACP's delete request has no cwd. Only delete sessions this connection has
923
- // already scoped through the broker; unknown ids remain the protocol no-op.
924
- if (!cwd) return {};
922
+ const pendingLocator = this.#pendingDeleteLocators.get(params.sessionId);
923
+ const cwd = record?.cwd ?? this.#knownSessionCwds.get(params.sessionId) ?? pendingLocator?.cwd;
924
+ // ACP's delete request has no cwd. Unknown ids remain the protocol no-op,
925
+ // while the broker can reconstruct an authenticated pending locator from its durable ledger.
926
+ if (!cwd) {
927
+ await (await this.#brokerAdapter()).global(
928
+ "session.delete",
929
+ { sessionId: params.sessionId },
930
+ this.#lifecycleIdempotencyKey(params.sessionId, "session.delete"),
931
+ );
932
+ return {};
933
+ }
925
934
  this.#beginTeardown(params.sessionId);
926
935
  try {
927
936
  await this.#teardownSession(params.sessionId, "deleted", true);
928
- let saved: string;
929
- try {
930
- saved = await this.#resolveSavedSession(params.sessionId, cwd);
931
- } catch (error) {
932
- if (error instanceof AcpSdkAdapterError && error.code === "not_found") {
933
- this.#knownSessionCwds.delete(params.sessionId);
934
- this.#knownSessionMetadata.delete(params.sessionId);
935
- return {};
937
+ let saved = pendingLocator?.cwd === cwd ? pendingLocator.path : undefined;
938
+ if (!saved) {
939
+ try {
940
+ saved = await this.#resolveSavedSession(params.sessionId, cwd);
941
+ } catch (error) {
942
+ if (error instanceof AcpSdkAdapterError && error.code === "not_found") {
943
+ this.#knownSessionCwds.delete(params.sessionId);
944
+ this.#knownSessionMetadata.delete(params.sessionId);
945
+ return {};
946
+ }
947
+ throw error;
936
948
  }
937
- throw error;
938
949
  }
950
+ this.#pendingDeleteLocators.set(params.sessionId, { cwd, path: saved });
939
951
  await (await this.#brokerAdapter()).global(
940
952
  "session.delete",
941
953
  { sessionId: params.sessionId, sessionPath: saved, cwd, target: { path: cwd } },
@@ -943,6 +955,7 @@ export class AcpAgent implements Agent {
943
955
  );
944
956
  this.#knownSessionCwds.delete(params.sessionId);
945
957
  this.#knownSessionMetadata.delete(params.sessionId);
958
+ this.#pendingDeleteLocators.delete(params.sessionId);
946
959
  return {};
947
960
  } finally {
948
961
  this.#finishTeardown(params.sessionId);
@@ -2168,6 +2181,7 @@ export class AcpAgent implements Agent {
2168
2181
  this.#resolvingExisting.clear();
2169
2182
  this.#knownSessionCwds.clear();
2170
2183
  this.#knownSessionMetadata.clear();
2184
+ this.#pendingDeleteLocators.clear();
2171
2185
  this.#pendingCloseIdempotencyKeys.clear();
2172
2186
  if (this.#closing.size === 0) this.#closing.clear();
2173
2187
  this.#tearingDown.clear();
@@ -56,6 +56,7 @@ export interface GjcBundleSettingsDependencies {
56
56
  export interface GjcBundleSettingsCallbacks {
57
57
  onClose: () => void;
58
58
  onBundlesChanged?: () => void;
59
+ onRenderRequested?: () => void;
59
60
  }
60
61
 
61
62
  export type GjcBundleSettingsState =
@@ -187,6 +188,7 @@ export class GjcBundleSettingsComponent extends Container {
187
188
  ),
188
189
  );
189
190
  this.addChild(new DynamicBorder());
191
+ this.callbacks.onRenderRequested?.();
190
192
  }
191
193
 
192
194
  #renderList(): void {
@@ -127,6 +127,7 @@ export interface PluginDetailCallbacks {
127
127
  onFeatureChange: (feature: string, enabled: boolean) => void;
128
128
  onConfigChange: (key: string, value: unknown) => void;
129
129
  onBack: () => void;
130
+ onRenderRequested?: () => void;
130
131
  }
131
132
 
132
133
  /**
@@ -288,6 +289,7 @@ export class PluginDetailComponent extends Container {
288
289
  this.addChild(new Spacer(1));
289
290
  this.addChild(new Text(theme.fg("dim", " Enter to edit · Esc to go back"), 0, 0));
290
291
  this.addChild(new DynamicBorder());
292
+ this.callbacks.onRenderRequested?.();
291
293
  }
292
294
 
293
295
  handleInput(data: string): void {
@@ -409,6 +411,7 @@ class ConfigInputSubmenu extends Container {
409
411
  export interface PluginSettingsCallbacks {
410
412
  onClose: () => void;
411
413
  onPluginChanged: () => void;
414
+ onRenderRequested?: () => void;
412
415
  }
413
416
 
414
417
  /** Component with handleInput method */
@@ -450,6 +453,7 @@ export class PluginSettingsComponent extends Container {
450
453
  });
451
454
 
452
455
  this.addChild(this.#viewComponent);
456
+ this.callbacks.onRenderRequested?.();
453
457
  }
454
458
 
455
459
  #showPluginDetail(plugin: InstalledPlugin): void {
@@ -477,6 +481,7 @@ export class PluginSettingsComponent extends Container {
477
481
  this.callbacks.onPluginChanged();
478
482
  },
479
483
  onBack: () => this.#showPluginList(),
484
+ onRenderRequested: () => this.callbacks.onRenderRequested?.(),
480
485
  });
481
486
 
482
487
  this.addChild(this.#viewComponent);
@@ -719,6 +719,8 @@ export interface SettingsCallbacks {
719
719
  getStatusLinePreview?: (width?: number) => string;
720
720
  /** Called when plugins change */
721
721
  onPluginsChanged?: () => void;
722
+ /** Called when asynchronously rebuilt settings content needs a repaint. */
723
+ onRenderRequested?: () => void;
722
724
  /** Called when an interactive setting cannot be committed. */
723
725
  onError?: (message: string) => void;
724
726
  /** Called when settings panel is closed */
@@ -1287,6 +1289,7 @@ export class SettingsSelectorComponent extends Container {
1287
1289
  this.#pluginComponent = new PluginSettingsComponent(this.context.cwd, {
1288
1290
  onClose: () => this.callbacks.onCancel(),
1289
1291
  onPluginChanged: () => this.callbacks.onPluginsChanged?.(),
1292
+ onRenderRequested: () => this.callbacks.onRenderRequested?.(),
1290
1293
  });
1291
1294
  this.addChild(this.#pluginComponent);
1292
1295
  }
@@ -1296,6 +1299,7 @@ export class SettingsSelectorComponent extends Container {
1296
1299
  {
1297
1300
  onClose: () => this.callbacks.onCancel(),
1298
1301
  onBundlesChanged: () => this.callbacks.onPluginsChanged?.(),
1302
+ onRenderRequested: () => this.callbacks.onRenderRequested?.(),
1299
1303
  },
1300
1304
  {
1301
1305
  runtimeSnapshotProvider: this.context.gjcRuntimeSnapshot,
@@ -1334,6 +1334,7 @@ export class SelectorController {
1334
1334
  onPluginsChanged: () => {
1335
1335
  this.ctx.ui.requestRender();
1336
1336
  },
1337
+ onRenderRequested: () => this.ctx.ui.requestRender(),
1337
1338
  onCancel: () => {
1338
1339
  done();
1339
1340
  // Restore status line to saved settings
@@ -1,8 +1,8 @@
1
- Start a background monitor that streams events from a long-running script. Each stdout line is an event — you keep working and notifications arrive in the chat. Events arrive on their own schedule and are not replies from the user, even if one lands while you're waiting for the user to answer a question.
1
+ Start a background monitor that streams events from a long-running script. Each stdout line is captured; persistent notifications are latest-biased and coalesced over a short debounce window, while terminal completion flushes the newest pending line. Events arrive on their own schedule and are not replies from the user, even if one lands while you're waiting for the user to answer a question.
2
2
 
3
3
  Pick by how many notifications you need:
4
4
  - **One** ("tell me when the server is ready / the build finishes") → use `bash` with `async: true`. That returns a single completion notification when the command exits.
5
- - **Many ongoing events** (logs, polling, file watching) → use `monitor`. The script keeps running and every new line of stdout becomes one event delivered into the conversation between turns.
5
+ - **Many ongoing events** (logs, polling, file watching) → use `monitor`. The script keeps running and new stdout is captured; persistent notifications are coalesced so ordinary log traffic does not create one model turn per line.
6
6
 
7
7
  `monitor` uses the same permission rules as `bash`. To stop a monitor, cancel its background task via `job` with the returned `task_id`, or end the session.
8
8
 
@@ -15,7 +15,7 @@ Pick by how many notifications you need:
15
15
 
16
16
  ## Inputs
17
17
 
18
- - `command` (required): shell command to run as a background monitor. Each stdout line is delivered as a separate task-notification event.
18
+ - `command` (required): shell command to run as a background monitor. Stdout is captured line-by-line; persistent notifications are coalesced before delivery.
19
19
  - `kind` (required): one of `"log"`, `"poll"`, `"watch"`, `"other"`. Describes the monitoring strategy so listings can surface useful categories.
20
20
  - `description` (required): short human-readable description of what is being monitored. Appears in task listings.
21
21
  - `timeout` (optional): maximum wall-clock seconds the monitor may run before automatic shutdown. Omit for the session lifetime.
@@ -23,7 +23,7 @@ Pick by how many notifications you need:
23
23
 
24
24
  ## Output
25
25
 
26
- Returns `Monitor started · task <task_id>` plus a task entry visible via `job({list: true})`. Each stdout line of the monitored command becomes a `<task-notification>` event delivered between turns.
26
+ Returns `Monitor started · task <task_id>` plus a task entry visible via `job({list: true})`. Persistent notifications contain the latest line and the count of earlier coalesced lines; terminal completion flushes the newest pending line.
27
27
 
28
28
  ## Cancellation
29
29
 
@@ -24,7 +24,7 @@ import {
24
24
  redactBrokerDiscovery,
25
25
  } from "./discovery";
26
26
  import { deriveIdempotencyIdentity } from "./identity";
27
- import { executeLifecycle, isCanonicalSessionId } from "./lifecycle";
27
+ import { canonicalDeleteLocatorPath, executeLifecycle, isCanonicalSessionId } from "./lifecycle";
28
28
 
29
29
  import {
30
30
  type LifecycleDurableEffectsReceipt,
@@ -71,6 +71,7 @@ export type BrokerErrorCode =
71
71
  export type BrokerCleanupIdentity = {
72
72
  dev: string;
73
73
  ino: string;
74
+ nlink?: string;
74
75
  size: number;
75
76
  mtimeNs: string;
76
77
  sha256: string;
@@ -102,6 +103,7 @@ export type BrokerArtifactTree = {
102
103
 
103
104
  export type BrokerCleanupEvidence = {
104
105
  phase: "artifacts" | "transcript" | "metadata" | "lifecycle";
106
+ cleanupReceiptVersion?: 1;
105
107
  /** Ledger-bound deletion target; never reconstructed from a retry request. */
106
108
  sessionsRoot?: string;
107
109
  transcriptPath?: string;
@@ -110,6 +112,7 @@ export type BrokerCleanupEvidence = {
110
112
  sessionId?: string;
111
113
  artifactsIdentity?: BrokerCleanupIdentity;
112
114
  transcriptIdentity?: BrokerCleanupIdentity;
115
+ transcriptParentIdentity?: { dev: string; ino: string };
113
116
  /** Identity-bound lifecycle metadata marker retained when exact cleanup is deferred. */
114
117
  metadataIdentity?: BrokerCleanupIdentity;
115
118
  metadataPath?: string;
@@ -122,9 +125,17 @@ export type BrokerCleanupEvidence = {
122
125
  /** Append-only terminal proof for lifecycle metadata cleanup. */
123
126
  metadataCompleted?: true;
124
127
  detachedArtifactsPath?: string;
128
+ retainedArtifactsSuccessorPath?: string;
129
+ retainedArtifactsPlaceholderPath?: string;
130
+ retainedArtifactsUnknownPath?: string;
131
+ retainedArtifactsSideAuthority?: "none" | "retained";
125
132
  detachedTranscriptPath?: string;
133
+ retainedTranscriptSuccessorPath?: string;
134
+ retainedTranscriptPlaceholderPath?: string;
135
+ retainedTranscriptUnknownPath?: string;
126
136
  /** Durable proof that artifact cleanup completed before transcript mutation. */
127
137
  artifactsRemoved?: boolean;
138
+ artifactsAbsentAtAuthorization?: true;
128
139
  /** Preauthorized no-replace artifact quarantine path persisted before detach. */
129
140
  plannedArtifactsPath?: string;
130
141
  /** Identity-bound artifact tree authority persisted before broker detach and replayed exactly. */
@@ -133,6 +144,7 @@ export type BrokerCleanupEvidence = {
133
144
  plannedTranscriptPath?: string;
134
145
  /** Fully identity-bound startup-failure cleanup plan, persisted before any detach. */
135
146
  lifecycleFiles?: BrokerLifecycleCleanupFile[];
147
+ lifecycleParentIdentity?: { dev: string; ino: string };
136
148
  /** Delete metadata receipts authorize only the canonical marker/ready sibling pair. */
137
149
  lifecycleDeleteMetadata?: true;
138
150
  };
@@ -157,6 +169,14 @@ function isCleanupPending(response: BrokerResponse): boolean {
157
169
  return !response.ok && response.error.code === "cleanup_pending" && response.error.cleanup !== undefined;
158
170
  }
159
171
 
172
+ function cleanupFromResponse(response: unknown): BrokerCleanupEvidence | undefined {
173
+ return isBrokerResponse(response) && !response.ok ? response.error.cleanup : undefined;
174
+ }
175
+ function pendingCleanupSessionId(response: BrokerResponse): string | undefined {
176
+ if (response.ok || response.error.code !== "cleanup_pending") return undefined;
177
+ return typeof response.error.cleanup?.sessionId === "string" ? response.error.cleanup.sessionId : undefined;
178
+ }
179
+
160
180
  function lifecycleResponseState(response: BrokerResponse): LifecycleState {
161
181
  if (response.ok) return "terminal_ok";
162
182
  if (isCleanupPending(response)) return "effect_started";
@@ -165,8 +185,8 @@ function lifecycleResponseState(response: BrokerResponse): LifecycleState {
165
185
 
166
186
  type InputNormalization = { input: Record<string, unknown> } | BrokerResponse;
167
187
 
168
- function isBrokerResponse(value: InputNormalization): value is BrokerResponse {
169
- return "ok" in value;
188
+ function isBrokerResponse(value: unknown): value is BrokerResponse {
189
+ return typeof value === "object" && value !== null && "ok" in value && typeof value.ok === "boolean";
170
190
  }
171
191
 
172
192
  function normalizeAliasedString(
@@ -224,11 +244,12 @@ function normalizeBrokerInput(operation: string, input: Record<string, unknown>)
224
244
  typeof input.target === "object" && input.target !== null && !Array.isArray(input.target)
225
245
  ? (input.target as Record<string, unknown>)
226
246
  : undefined;
247
+ const normalizeLifecycleDirectory = operation === "session.delete" ? canonicalDeleteLocatorPath : path.resolve;
227
248
  const cwd = normalizeAliasedString(
228
249
  { cwd: input.cwd, path: input.path, targetPath: target?.path },
229
250
  "cwd",
230
251
  ["path", "targetPath"],
231
- value => path.resolve(value),
252
+ normalizeLifecycleDirectory,
232
253
  );
233
254
  if (cwd.error) return error("invalid_input", cwd.error);
234
255
  if (cwd.value !== undefined) {
@@ -239,7 +260,7 @@ function normalizeBrokerInput(operation: string, input: Record<string, unknown>)
239
260
  { stateRoot: input.stateRoot, targetStateRoot: target?.stateRoot },
240
261
  "stateRoot",
241
262
  ["targetStateRoot"],
242
- value => path.resolve(value),
263
+ normalizeLifecycleDirectory,
243
264
  );
244
265
  if (stateRoot.error) return error("invalid_input", stateRoot.error);
245
266
  if (stateRoot.value !== undefined && (!cwd.value || stateRoot.value !== path.join(cwd.value, ".gjc", "state")))
@@ -254,6 +275,11 @@ function normalizeBrokerInput(operation: string, input: Record<string, unknown>)
254
275
  if (Object.keys(normalizedTarget).length > 0) normalized.target = normalizedTarget;
255
276
  else delete normalized.target;
256
277
  }
278
+ if (operation === "session.delete") {
279
+ const sessionPath = normalizeAliasedString(input, "sessionPath", [], canonicalDeleteLocatorPath);
280
+ if (sessionPath.error) return error("invalid_input", sessionPath.error);
281
+ if (sessionPath.value !== undefined) normalized.sessionPath = sessionPath.value;
282
+ }
257
283
  return { input: normalized };
258
284
  }
259
285
  function canonicalJson(value: unknown): string {
@@ -798,7 +824,61 @@ export class Broker {
798
824
  .update(canonicalJson(lifecycleTarget(operation, input)))
799
825
  .digest("hex");
800
826
  const identity = await deriveIdempotencyIdentity(this.settings.agentDir, operation, idempotencyKey, target);
801
- const requestHash = createHash("sha256").update(canonicalJson({ operation, input })).digest("hex");
827
+ let reconstructedDeleteCleanup: BrokerCleanupEvidence | undefined;
828
+ if (operation === "session.delete" && input.cwd === undefined && input.sessionPath === undefined) {
829
+ const entry = this.ledger.get(identity);
830
+ const cleanup = cleanupFromResponse(entry?.response) ?? cleanupFromResponse(entry?.unresolvedCleanupResponse);
831
+ reconstructedDeleteCleanup = cleanup;
832
+ const requestedSessionId = typeof input.sessionId === "string" ? input.sessionId : undefined;
833
+ if (!cleanup) {
834
+ if (requestedSessionId && this.ledger.hasUncertainCleanupForSession(requestedSessionId, identity))
835
+ return error(
836
+ "terminal_uncertain",
837
+ "Session cleanup authority is uncertain and cannot be deleted safely",
838
+ );
839
+ const pending = requestedSessionId
840
+ ? this.ledger.findCleanupPendingBySessionId(requestedSessionId, identity)
841
+ : undefined;
842
+ if (pending) {
843
+ const pendingResponse = cleanupFromResponse(pending.response)
844
+ ? pending.response
845
+ : pending.unresolvedCleanupResponse;
846
+ if (isBrokerResponse(pendingResponse)) return pendingResponse;
847
+ return error(
848
+ "terminal_uncertain",
849
+ "Session cleanup authority is pending under another lifecycle identity",
850
+ );
851
+ }
852
+ if (entry) {
853
+ if (isBrokerResponse(entry.response)) return entry.response;
854
+ return error("terminal_uncertain", "Existing session.delete ledger evidence lacks replayable authority");
855
+ }
856
+ if (requestedSessionId) {
857
+ await this.index.refresh();
858
+ if (this.index.listSessions().sessions.some(session => session.sessionId === requestedSessionId))
859
+ return error(
860
+ "terminal_uncertain",
861
+ "Indexed session requires durable locator authority before deletion",
862
+ );
863
+ }
864
+ return { ok: true, result: requestedSessionId ? { sessionId: requestedSessionId } : undefined };
865
+ }
866
+ if (
867
+ cleanup &&
868
+ cleanup.sessionId === requestedSessionId &&
869
+ typeof cleanup.cwd === "string" &&
870
+ typeof cleanup.transcriptPath === "string"
871
+ )
872
+ input = {
873
+ sessionId: cleanup.sessionId,
874
+ cwd: cleanup.cwd,
875
+ stateRoot: path.join(cleanup.cwd, ".gjc", "state"),
876
+ sessionPath: cleanup.transcriptPath,
877
+ };
878
+ }
879
+ const storedRequestHash = reconstructedDeleteCleanup ? this.ledger.get(identity)?.requestHash : undefined;
880
+ const requestHash =
881
+ storedRequestHash ?? createHash("sha256").update(canonicalJson({ operation, input })).digest("hex");
802
882
  const prev = this.#chains.get(target) ?? Promise.resolve();
803
883
  let release!: () => void;
804
884
  const current = new Promise<void>(resolve => (release = resolve));
@@ -812,8 +892,8 @@ export class Broker {
812
892
  const begun = await this.ledger.begin(identity, requestHash);
813
893
  if (begun.kind === "replay") {
814
894
  const replay = begun.entry.response as BrokerResponse;
815
- if (!(!replay.ok && replay.error.cleanup)) return replay;
816
- const cleanup = replay.error.cleanup;
895
+ const cleanup = cleanupFromResponse(replay) ?? reconstructedDeleteCleanup;
896
+ if (!cleanup) return replay;
817
897
  const outcome = await executeLifecycle(this, operation, input, identity, cleanup);
818
898
  const response = outcome.response;
819
899
  await this.ledger.transition(identity, lifecycleResponseState(response), {
@@ -828,11 +908,13 @@ export class Broker {
828
908
  return error("idempotency_conflict", "idempotency key was used with a different request");
829
909
  if (begun.kind === "terminal_uncertain") {
830
910
  const replay = (begun.entry.response ?? beforeBegin?.response) as BrokerResponse | undefined;
831
- if (!replay || replay.ok || !replay.error.cleanup)
911
+ const cleanup = (replay ? cleanupFromResponse(replay) : undefined) ?? reconstructedDeleteCleanup;
912
+ if (!cleanup)
832
913
  return replay ?? error("terminal_uncertain", "prior lifecycle operation outcome is uncertain");
833
- const outcome = await executeLifecycle(this, operation, input, identity, replay.error.cleanup);
914
+ const outcome = await executeLifecycle(this, operation, input, identity, cleanup);
834
915
  const response = outcome.response;
835
916
  await this.ledger.transition(identity, lifecycleResponseState(response), {
917
+ ...(pendingCleanupSessionId(response) ? { intendedSessionId: pendingCleanupSessionId(response) } : {}),
836
918
  response,
837
919
  responseDigest: createHash("sha256").update(canonicalJson(response)).digest("hex"),
838
920
  ...(outcome.durableEffects ? { durableEffects: outcome.durableEffects } : {}),
@@ -844,6 +926,7 @@ export class Broker {
844
926
  const outcome = await executeLifecycle(this, operation, input, identity);
845
927
  const response = outcome.response;
846
928
  await this.ledger.transition(identity, lifecycleResponseState(response), {
929
+ ...(pendingCleanupSessionId(response) ? { intendedSessionId: pendingCleanupSessionId(response) } : {}),
847
930
  resultSessionId:
848
931
  response.ok && typeof (response.result as { sessionId?: unknown } | undefined)?.sessionId === "string"
849
932
  ? (response.result as { sessionId: string }).sessionId