@gajae-code/coding-agent 0.12.5 → 0.12.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 (127) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +4 -1
  3. package/dist/types/eval/backend.d.ts +2 -2
  4. package/dist/types/exec/bash-executor.d.ts +10 -8
  5. package/dist/types/extensibility/extensions/runner.d.ts +9 -7
  6. package/dist/types/gjc-runtime/launch-tmux.d.ts +22 -3
  7. package/dist/types/gjc-runtime/psmux-detect.d.ts +3 -0
  8. package/dist/types/gjc-runtime/team-launch.d.ts +3 -1
  9. package/dist/types/gjc-runtime/team-runtime.d.ts +14 -2
  10. package/dist/types/gjc-runtime/team-store.d.ts +0 -4
  11. package/dist/types/gjc-runtime/team-workers.d.ts +5 -0
  12. package/dist/types/gjc-runtime/tmux-common.d.ts +1 -0
  13. package/dist/types/gjc-runtime/tmux-provider-context.d.ts +57 -0
  14. package/dist/types/gjc-runtime/tmux-sessions.d.ts +8 -1
  15. package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +3 -0
  16. package/dist/types/gjc-runtime/ultragoal-review-source.d.ts +83 -0
  17. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +57 -4
  18. package/dist/types/internal-urls/registry-helpers.d.ts +18 -0
  19. package/dist/types/modes/components/gjc-bundle-settings.d.ts +1 -0
  20. package/dist/types/modes/components/plugin-settings.d.ts +2 -0
  21. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  22. package/dist/types/sdk/broker/broker.d.ts +18 -0
  23. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +13 -0
  24. package/dist/types/sdk/broker/lifecycle.d.ts +1 -0
  25. package/dist/types/sdk/bus/chat-daemon-control.d.ts +4 -3
  26. package/dist/types/sdk/bus/index.d.ts +4 -0
  27. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +4 -2
  28. package/dist/types/sdk/bus/telegram-daemon.d.ts +25 -3
  29. package/dist/types/sdk/bus/topic-registry.d.ts +63 -3
  30. package/dist/types/sdk/host/host.d.ts +23 -2
  31. package/dist/types/sdk/host/reverse-leases.d.ts +7 -4
  32. package/dist/types/session/agent-session.d.ts +5 -4
  33. package/dist/types/session/artifacts.d.ts +8 -5
  34. package/dist/types/session/attempt-record-store.d.ts +19 -0
  35. package/dist/types/session/client-bridge.d.ts +14 -0
  36. package/dist/types/session/internal/managed-session-storage.d.ts +5 -2
  37. package/dist/types/session/messages.d.ts +4 -1
  38. package/dist/types/session/session-manager.d.ts +6 -4
  39. package/dist/types/session/session-storage.d.ts +8 -0
  40. package/dist/types/session/streaming-output.d.ts +32 -0
  41. package/dist/types/task/receipt.d.ts +2 -0
  42. package/dist/types/task/types.d.ts +178 -0
  43. package/dist/types/tools/bash.d.ts +1 -1
  44. package/dist/types/tools/fetch.d.ts +3 -0
  45. package/dist/types/tools/output-meta.d.ts +37 -5
  46. package/dist/types/tools/tool-result.d.ts +1 -0
  47. package/dist/types/utils/artifact-id.d.ts +2 -0
  48. package/package.json +8 -8
  49. package/scripts/capture-sticky-viewport-showcase.ts +131 -5
  50. package/scripts/verify-gjc-sdk-canonicalization.ts +128 -183
  51. package/scripts/verify-sticky-viewport-showcase.ts +620 -93
  52. package/src/config/model-profiles.ts +8 -0
  53. package/src/config/settings-schema.ts +17 -3
  54. package/src/cursor.ts +20 -13
  55. package/src/defaults/gjc/skills/ultragoal/SKILL.md +5 -2
  56. package/src/eval/backend.ts +2 -2
  57. package/src/exec/bash-executor.ts +425 -151
  58. package/src/extensibility/extensions/runner.ts +80 -6
  59. package/src/extensibility/extensions/wrapper.ts +22 -15
  60. package/src/extensibility/runtime-skill-discovery.ts +4 -6
  61. package/src/gjc-runtime/launch-tmux.ts +309 -293
  62. package/src/gjc-runtime/managed-owner-supervisor.ts +6 -2
  63. package/src/gjc-runtime/psmux-detect.ts +52 -6
  64. package/src/gjc-runtime/team-launch.ts +50 -2
  65. package/src/gjc-runtime/team-runtime.ts +716 -251
  66. package/src/gjc-runtime/team-store.ts +25 -7
  67. package/src/gjc-runtime/team-workers.ts +161 -1
  68. package/src/gjc-runtime/tmux-common.ts +16 -4
  69. package/src/gjc-runtime/tmux-gc.ts +37 -5
  70. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -0
  71. package/src/gjc-runtime/tmux-provider-context.ts +451 -0
  72. package/src/gjc-runtime/tmux-sessions.ts +409 -156
  73. package/src/gjc-runtime/ultragoal-change-set.ts +90 -44
  74. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +7 -0
  75. package/src/gjc-runtime/ultragoal-review-source.ts +378 -0
  76. package/src/gjc-runtime/ultragoal-runtime.ts +640 -159
  77. package/src/gjc-runtime/workflow-manifest.generated.json +40 -1
  78. package/src/gjc-runtime/workflow-manifest.ts +12 -0
  79. package/src/internal-urls/agent-protocol.ts +98 -38
  80. package/src/internal-urls/artifact-protocol.ts +28 -17
  81. package/src/internal-urls/docs-index.generated.ts +5 -5
  82. package/src/internal-urls/local-protocol.ts +39 -18
  83. package/src/internal-urls/registry-helpers.ts +62 -0
  84. package/src/modes/acp/acp-agent.ts +51 -13
  85. package/src/modes/acp/acp-client-bridge.ts +154 -11
  86. package/src/modes/components/gjc-bundle-settings.ts +2 -0
  87. package/src/modes/components/plugin-settings.ts +5 -0
  88. package/src/modes/components/settings-selector.ts +4 -0
  89. package/src/modes/controllers/selector-controller.ts +1 -0
  90. package/src/prompts/tools/bash.md +2 -2
  91. package/src/prompts/tools/monitor.md +4 -4
  92. package/src/sdk/acp/adapter.ts +200 -9
  93. package/src/sdk/broker/broker.ts +93 -10
  94. package/src/sdk/broker/lifecycle-ledger.ts +227 -11
  95. package/src/sdk/broker/lifecycle.ts +691 -92
  96. package/src/sdk/bus/chat-daemon-control.ts +32 -6
  97. package/src/sdk/bus/index.ts +347 -14
  98. package/src/sdk/bus/telegram-daemon-contract.ts +4 -2
  99. package/src/sdk/bus/telegram-daemon.ts +283 -44
  100. package/src/sdk/bus/topic-registry.ts +133 -10
  101. package/src/sdk/host/host.ts +187 -9
  102. package/src/sdk/host/reverse-leases.ts +249 -42
  103. package/src/sdk/session.ts +7 -6
  104. package/src/session/agent-session.ts +930 -354
  105. package/src/session/artifacts.ts +211 -74
  106. package/src/session/attempt-record-store.ts +106 -0
  107. package/src/session/client-bridge.ts +15 -0
  108. package/src/session/internal/managed-session-scope.ts +252 -67
  109. package/src/session/internal/managed-session-storage.ts +105 -5
  110. package/src/session/messages.ts +32 -7
  111. package/src/session/session-manager.ts +19 -21
  112. package/src/session/session-storage.ts +146 -24
  113. package/src/session/streaming-output.ts +198 -40
  114. package/src/task/index.ts +45 -0
  115. package/src/task/provider-retry-status.ts +4 -1
  116. package/src/task/receipt.ts +4 -0
  117. package/src/task/render.ts +30 -0
  118. package/src/task/types.ts +34 -0
  119. package/src/tools/bash.ts +818 -239
  120. package/src/tools/eval.ts +99 -4
  121. package/src/tools/fetch.ts +77 -15
  122. package/src/tools/find.ts +7 -3
  123. package/src/tools/monitor.ts +7 -1
  124. package/src/tools/output-meta.ts +706 -116
  125. package/src/tools/search.ts +9 -4
  126. package/src/tools/tool-result.ts +1 -0
  127. package/src/utils/artifact-id.ts +6 -0
@@ -42,6 +42,9 @@ export type AcpReconnectFailedHandler = (error: SdkClientError) => void;
42
42
  export type AcpFrameHandler = (frame: SdkFrame) => void;
43
43
  type ReverseRequest = {
44
44
  state: "pending" | "cancelled";
45
+ connectionId?: string;
46
+ capability?: string;
47
+ leaseId?: string;
45
48
  controller?: AbortController;
46
49
  cancelTimer?: NodeJS.Timeout;
47
50
  };
@@ -65,6 +68,15 @@ function isLifecycleOperation(operation: string): boolean {
65
68
  );
66
69
  }
67
70
 
71
+ const MAX_PENDING_REVERSE_PROVIDER_CALLS = 64;
72
+
73
+ type ProviderCallToken = {
74
+ method: string;
75
+ promise?: Promise<unknown>;
76
+ };
77
+
78
+ const PROVIDER_CLOSE_SETTLEMENT_MS = 1_000;
79
+
68
80
  /**
69
81
  * Pure ACP-to-SDK adapter. It deliberately owns neither an AgentSession nor an
70
82
  * ACP bridge: all session work is performed through authenticated v3 frames.
@@ -83,10 +95,13 @@ export class AcpSdkAdapter {
83
95
  #reclaiming?: Promise<void>;
84
96
  #reverseRequests = new Map<string, ReverseRequest>();
85
97
  #reconnectFailedHandlers = new Set<AcpReconnectFailedHandler>();
98
+ #pendingReverseProviderCalls = new Set<ProviderCallToken>();
86
99
  #frameHandlers = new Set<AcpFrameHandler>();
87
100
 
88
101
  #reverseCancelTtlMs: number;
89
102
  #closed = false;
103
+ #closePromise?: Promise<void>;
104
+ #terminalCleanupUncertain = false;
90
105
 
91
106
  constructor(options: AcpSdkAdapterOptions) {
92
107
  this.#client = options.client ?? new SdkClient(options.url, options.token);
@@ -140,10 +155,16 @@ export class AcpSdkAdapter {
140
155
  }
141
156
 
142
157
  async close(): Promise<void> {
158
+ this.#closePromise ??= this.#closeInternal();
159
+ await this.#closePromise;
160
+ }
161
+
162
+ async #closeInternal(): Promise<void> {
143
163
  if (this.#closed) return;
144
164
  this.#closed = true;
145
165
  if (this.#heartbeat) clearInterval(this.#heartbeat);
146
- for (const request of this.#reverseRequests.values()) {
166
+ for (const [id, request] of this.#reverseRequests) {
167
+ if (request.state === "pending") this.#sendReverseCancellation(id, request);
147
168
  request.controller?.abort();
148
169
  if (request.cancelTimer) clearTimeout(request.cancelTimer);
149
170
  }
@@ -151,7 +172,21 @@ export class AcpSdkAdapter {
151
172
  this.#unsubscribe?.();
152
173
  this.#unsubscribeReconnect?.();
153
174
  this.#unsubscribeReconnectFailed?.();
154
- await this.#client.close();
175
+
176
+ const terminalSettled = await this.#awaitTerminalProviderSettlement();
177
+ let clientError: unknown;
178
+ try {
179
+ await this.#client.close();
180
+ } catch (error) {
181
+ clientError = error;
182
+ }
183
+ if (!terminalSettled) {
184
+ throw new AcpSdkAdapterError(
185
+ "ownership_uncertain",
186
+ "ACP terminal provider cleanup did not settle before adapter close.",
187
+ );
188
+ }
189
+ if (clientError) throw clientError;
155
190
  }
156
191
 
157
192
  async prompt(params: JsonObject | string): Promise<unknown> {
@@ -355,7 +390,7 @@ export class AcpSdkAdapter {
355
390
  )
356
391
  return;
357
392
  const controller = new AbortController();
358
- const active: ReverseRequest = { state: "pending", controller };
393
+ const active: ReverseRequest = { state: "pending", connectionId, capability, leaseId, controller };
359
394
  this.#reverseRequests.set(id, active);
360
395
  try {
361
396
  const request = frame.payload as JsonObject | undefined;
@@ -423,19 +458,175 @@ export class AcpSdkAdapter {
423
458
 
424
459
  #abortActiveReverseRequests(): void {
425
460
  for (const [id, request] of this.#reverseRequests) {
461
+ if (request.state === "pending") this.#sendReverseCancellation(id, request);
426
462
  request.state = "cancelled";
427
463
  request.controller?.abort();
428
464
  this.#finishReverse(id, request);
429
465
  }
430
466
  }
431
467
 
468
+ #sendReverseCancellation(id: string, request: ReverseRequest): void {
469
+ if (!request.connectionId || !request.leaseId) return;
470
+ try {
471
+ const cancellation = this.#client.send({
472
+ type: "reverse_cancel",
473
+ id,
474
+ connectionId: request.connectionId,
475
+ leaseId: request.leaseId,
476
+ });
477
+ void Promise.resolve(cancellation).catch(() => {});
478
+ } catch {
479
+ // Cancellation is best effort while local retirement remains authoritative.
480
+ }
481
+ }
482
+
483
+ async #awaitTerminalProviderSettlement(): Promise<boolean> {
484
+ const deadline = Date.now() + PROVIDER_CLOSE_SETTLEMENT_MS;
485
+ for (;;) {
486
+ const pending = [...this.#pendingReverseProviderCalls].filter(token =>
487
+ ["terminal.create", "terminal.kill", "terminal.release"].includes(token.method),
488
+ );
489
+ if (pending.length === 0) return !this.#terminalCleanupUncertain;
490
+ const remaining = deadline - Date.now();
491
+ if (remaining <= 0) return false;
492
+ const promises = pending.flatMap(token => (token.promise ? [token.promise] : []));
493
+ if (promises.length === 0) {
494
+ await Bun.sleep(Math.min(remaining, 1));
495
+ continue;
496
+ }
497
+ await Promise.race([Promise.allSettled(promises), Bun.sleep(remaining)]);
498
+ }
499
+ }
500
+
501
+ #reserveProviderCalls(method: string): { call: ProviderCallToken; cleanup: ProviderCallToken[] } {
502
+ const cleanup =
503
+ method === "terminal.create"
504
+ ? ([{ method: "terminal.kill" }, { method: "terminal.release" }] satisfies ProviderCallToken[])
505
+ : [];
506
+ const required = 1 + cleanup.length;
507
+ if (this.#pendingReverseProviderCalls.size + required > MAX_PENDING_REVERSE_PROVIDER_CALLS)
508
+ throw new AcpSdkAdapterError("acp_reverse_capacity", "ACP reverse provider call capacity reached.");
509
+ const call: ProviderCallToken = { method };
510
+ this.#pendingReverseProviderCalls.add(call);
511
+ for (const token of cleanup) this.#pendingReverseProviderCalls.add(token);
512
+ return { call, cleanup };
513
+ }
514
+
515
+ #releaseReservedProviderCalls(tokens: readonly ProviderCallToken[]): void {
516
+ for (const token of tokens) {
517
+ if (!token.promise) this.#pendingReverseProviderCalls.delete(token);
518
+ }
519
+ }
520
+
521
+ #startProviderCall(
522
+ method: string,
523
+ payload: JsonObject,
524
+ signal: AbortSignal | undefined,
525
+ token: ProviderCallToken,
526
+ ): Promise<unknown> {
527
+ let providerCall: Promise<unknown>;
528
+ try {
529
+ if (!this.#connection) throw new AcpSdkAdapterError("acp_reverse_unavailable");
530
+ if (this.#connection.request)
531
+ providerCall = Promise.resolve(this.#connection.request(method, payload, { cancellationSignal: signal }));
532
+ else {
533
+ const target = this.#connection[method];
534
+ if (typeof target !== "function")
535
+ throw new AcpSdkAdapterError("acp_reverse_unsupported", `ACP client does not support ${method}.`);
536
+ providerCall = Promise.resolve((target as (params: JsonObject) => Promise<unknown>)(payload));
537
+ }
538
+ } catch (error) {
539
+ if (token.method === "terminal.kill" || token.method === "terminal.release")
540
+ this.#terminalCleanupUncertain = true;
541
+ this.#pendingReverseProviderCalls.delete(token);
542
+ return Promise.reject(error);
543
+ }
544
+ token.promise = providerCall;
545
+ void providerCall.then(
546
+ () => this.#pendingReverseProviderCalls.delete(token),
547
+ () => {
548
+ if (token.method === "terminal.kill" || token.method === "terminal.release")
549
+ this.#terminalCleanupUncertain = true;
550
+ this.#pendingReverseProviderCalls.delete(token);
551
+ },
552
+ );
553
+ return providerCall;
554
+ }
555
+
432
556
  async #forwardReverse(method: string, payload: JsonObject, signal: AbortSignal): Promise<unknown> {
433
557
  if (!method || !this.#connection) throw new AcpSdkAdapterError("acp_reverse_unavailable");
434
- if (this.#connection.request)
435
- return await this.#connection.request(method, payload, { cancellationSignal: signal });
436
- const target = this.#connection[method];
437
- if (typeof target !== "function")
438
- throw new AcpSdkAdapterError("acp_reverse_unsupported", `ACP client does not support ${method}.`);
439
- return await (target as (params: JsonObject) => Promise<unknown>)(payload);
558
+ if (signal.aborted) throw new AcpSdkAdapterError("request_cancelled", "Reverse request was cancelled.");
559
+ const reservation = this.#reserveProviderCalls(method);
560
+ const providerCall = this.#startProviderCall(method, payload, signal, reservation.call);
561
+ const cancelled = Promise.withResolvers<{ status: "cancelled" }>();
562
+ const onAbort = () => cancelled.resolve({ status: "cancelled" });
563
+ signal.addEventListener("abort", onAbort, { once: true });
564
+ if (signal.aborted) cancelled.resolve({ status: "cancelled" });
565
+ let outcome:
566
+ | { status: "resolved"; result: unknown }
567
+ | { status: "rejected"; error: unknown }
568
+ | { status: "cancelled" };
569
+ try {
570
+ outcome = await Promise.race([
571
+ providerCall.then(
572
+ result => ({ status: "resolved" as const, result }),
573
+ error => ({ status: "rejected" as const, error }),
574
+ ),
575
+ cancelled.promise,
576
+ ]);
577
+ } finally {
578
+ signal.removeEventListener("abort", onAbort);
579
+ }
580
+ if (outcome.status === "cancelled" || signal.aborted) {
581
+ if (method === "terminal.create") {
582
+ void providerCall
583
+ .then(
584
+ result => this.#cleanupCancelledTerminalCreate(result, reservation.cleanup),
585
+ () => {
586
+ this.#releaseReservedProviderCalls(reservation.cleanup);
587
+ },
588
+ )
589
+ .catch(() => {
590
+ this.#terminalCleanupUncertain = true;
591
+ this.#releaseReservedProviderCalls(reservation.cleanup);
592
+ });
593
+ } else this.#releaseReservedProviderCalls(reservation.cleanup);
594
+ throw new AcpSdkAdapterError("request_cancelled", "Reverse request was cancelled.");
595
+ }
596
+ this.#releaseReservedProviderCalls(reservation.cleanup);
597
+ if (outcome.status === "rejected") throw outcome.error;
598
+ return outcome.result;
599
+ }
600
+
601
+ async #cleanupCancelledTerminalCreate(result: unknown, cleanupTokens: readonly ProviderCallToken[]): Promise<void> {
602
+ const terminalId =
603
+ result && typeof result === "object" && typeof (result as { terminalId?: unknown }).terminalId === "string"
604
+ ? (result as { terminalId: string }).terminalId
605
+ : undefined;
606
+ if (!terminalId || !this.#connection) {
607
+ this.#terminalCleanupUncertain = true;
608
+ this.#releaseReservedProviderCalls(cleanupTokens);
609
+ return;
610
+ }
611
+ for (const [index, method] of (["terminal.kill", "terminal.release"] as const).entries()) {
612
+ const token = cleanupTokens[index];
613
+ if (!token) continue;
614
+ let providerCall: Promise<unknown>;
615
+ try {
616
+ providerCall = this.#startProviderCall(method, { terminalId }, AbortSignal.timeout(500), token);
617
+ } catch {
618
+ this.#terminalCleanupUncertain = true;
619
+ this.#releaseReservedProviderCalls([token]);
620
+ continue;
621
+ }
622
+ const cleanupOutcome = await Promise.race([
623
+ providerCall.then(
624
+ () => "settled" as const,
625
+ () => "failed" as const,
626
+ ),
627
+ Bun.sleep(500).then(() => "timeout" as const),
628
+ ]);
629
+ if (cleanupOutcome === "failed") this.#terminalCleanupUncertain = true;
630
+ }
440
631
  }
441
632
  }
@@ -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