@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
@@ -9,8 +9,10 @@ import { createHash, randomUUID } from "node:crypto";
9
9
  import * as fs from "node:fs/promises";
10
10
 
11
11
  import * as path from "node:path";
12
+ import { isValidArtifactId } from "../utils/artifact-id";
12
13
  import {
13
14
  ensureManagedDirectory,
15
+ type ManagedFileSnapshot,
14
16
  type ManagedSessionDescendantStore,
15
17
  publishManagedFileNoReplace,
16
18
  } from "./internal/managed-session-storage";
@@ -33,6 +35,9 @@ function isSafeFilename(filename: string): boolean {
33
35
  return /^[a-zA-Z0-9_.-]+$/.test(filename);
34
36
  }
35
37
 
38
+ const MANAGED_OUTPUT_GENERATION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
39
+ const SHA256_PATTERN = /^[0-9a-f]{64}$/;
40
+
36
41
  function parseManagedOutputGeneration(value: Uint8Array, outputFilenamePrefix: string): ManagedOutputGeneration | null {
37
42
  try {
38
43
  const parsed = JSON.parse(Buffer.from(value).toString("utf8")) as Partial<ManagedOutputGeneration>;
@@ -43,10 +48,19 @@ function parseManagedOutputGeneration(value: Uint8Array, outputFilenamePrefix: s
43
48
  typeof parsed.outputSha256 !== "string" ||
44
49
  typeof parsed.metadataSizeBytes !== "number" ||
45
50
  typeof parsed.metadataSha256 !== "string" ||
51
+ !Number.isSafeInteger(parsed.outputSizeBytes) ||
52
+ (parsed.outputSizeBytes ?? -1) < 0 ||
53
+ !Number.isSafeInteger(parsed.metadataSizeBytes) ||
54
+ (parsed.metadataSizeBytes ?? -1) < 0 ||
55
+ !SHA256_PATTERN.test(parsed.outputSha256) ||
56
+ !SHA256_PATTERN.test(parsed.metadataSha256) ||
46
57
  !isSafeFilename(parsed.outputFilename) ||
47
58
  !isSafeFilename(parsed.metadataFilename) ||
48
59
  !parsed.outputFilename.startsWith(`${outputFilenamePrefix}.`) ||
49
60
  !parsed.outputFilename.endsWith(".output") ||
61
+ !MANAGED_OUTPUT_GENERATION_ID_PATTERN.test(
62
+ parsed.outputFilename.slice(outputFilenamePrefix.length + 1, -".output".length),
63
+ ) ||
50
64
  parsed.metadataFilename !== `${parsed.outputFilename}.meta.json`
51
65
  )
52
66
  return null;
@@ -57,12 +71,36 @@ function parseManagedOutputGeneration(value: Uint8Array, outputFilenamePrefix: s
57
71
  }
58
72
 
59
73
  function sameGeneration(left: ManagedOutputGeneration, right: ManagedOutputGeneration): boolean {
60
- return left.outputFilename === right.outputFilename && left.metadataFilename === right.metadataFilename;
74
+ return (
75
+ left.outputFilename === right.outputFilename &&
76
+ left.metadataFilename === right.metadataFilename &&
77
+ left.outputSizeBytes === right.outputSizeBytes &&
78
+ left.outputSha256 === right.outputSha256 &&
79
+ left.metadataSizeBytes === right.metadataSizeBytes &&
80
+ left.metadataSha256 === right.metadataSha256
81
+ );
82
+ }
83
+
84
+ function referencesGeneration(value: Uint8Array, generation: ManagedOutputGeneration): boolean {
85
+ try {
86
+ const parsed = JSON.parse(Buffer.from(value).toString("utf8")) as Partial<ManagedOutputGeneration>;
87
+ return (
88
+ parsed.outputFilename === generation.outputFilename && parsed.metadataFilename === generation.metadataFilename
89
+ );
90
+ } catch {
91
+ return false;
92
+ }
61
93
  }
62
94
  export interface ArtifactSaveOptions {
63
95
  maxBytes?: number;
64
96
  }
65
97
 
98
+ export interface ArtifactSaveReceipt {
99
+ id: string;
100
+ complete: boolean;
101
+ omittedBytes?: number;
102
+ }
103
+
66
104
  /**
67
105
  * Manages artifact storage for a session.
68
106
  *
@@ -79,6 +117,7 @@ export class ArtifactManager {
79
117
  readonly #store: ManagedSessionDescendantStore | undefined;
80
118
  #dirCreated = false;
81
119
  #initialized = false;
120
+ #initializing: Promise<void> | undefined;
82
121
 
83
122
  /**
84
123
  * @param dir Directory that will hold artifact files. Created lazily on first save.
@@ -113,15 +152,21 @@ export class ArtifactManager {
113
152
  }
114
153
 
115
154
  async #ensureDir(): Promise<void> {
116
- if (!this.#dirCreated) {
117
- if (this.#store) this.#store.ensureDirectory();
118
- else ensureManagedDirectory(this.#dir);
119
- this.#dirCreated = true;
120
- }
121
- if (!this.#initialized) {
122
- await this.#scanExistingIds();
123
- this.#initialized = true;
155
+ if (this.#initialized) return;
156
+ if (!this.#initializing) {
157
+ this.#initializing = (async () => {
158
+ if (!this.#dirCreated) {
159
+ if (this.#store) this.#store.ensureDirectory();
160
+ else ensureManagedDirectory(this.#dir);
161
+ this.#dirCreated = true;
162
+ }
163
+ await this.#scanExistingIds();
164
+ this.#initialized = true;
165
+ })().finally(() => {
166
+ this.#initializing = undefined;
167
+ });
124
168
  }
169
+ await this.#initializing;
125
170
  }
126
171
 
127
172
  #filename(id: string, toolType: string): string {
@@ -159,58 +204,116 @@ export class ArtifactManager {
159
204
  }
160
205
  await this.#ensureDir();
161
206
  if (!this.#store) throw new Error("Managed output generation requires retained authority");
207
+ const mutationFence = await this.#store.acquireMutationFence(`${selectorFilename}.publish`);
208
+ try {
209
+ const priorSelector = this.#store.readExpected(selectorFilename);
210
+ const priorGeneration = priorSelector
211
+ ? parseManagedOutputGeneration(priorSelector.bytes, outputFilenamePrefix)
212
+ : null;
213
+ const generationId = randomUUID();
214
+ const outputFilename = `${outputFilenamePrefix}.${generationId}.output`;
215
+ const metadataFilename = `${outputFilename}.meta.json`;
216
+ const generation: ManagedOutputGeneration = {
217
+ outputFilename,
218
+ metadataFilename,
219
+ outputSizeBytes: outputBytes.byteLength,
220
+ outputSha256: sha256(outputBytes),
221
+ metadataSizeBytes: metadataBytes.byteLength,
222
+ metadataSha256: sha256(metadataBytes),
223
+ };
224
+
225
+ // Immutable generations are not visible until the selector is replaced.
226
+ let stagedOutput: ManagedFileSnapshot | undefined;
227
+ let stagedMetadata: ManagedFileSnapshot | undefined;
228
+ const removeStaged = () => {
229
+ for (const [filename, snapshot] of [
230
+ [metadataFilename, stagedMetadata],
231
+ [outputFilename, stagedOutput],
232
+ ] as const) {
233
+ if (!snapshot) continue;
234
+ try {
235
+ this.#store!.removeExpected(filename, snapshot);
236
+ } catch {
237
+ // An identity mismatch is not authority to remove a successor.
238
+ }
239
+ }
240
+ };
241
+ try {
242
+ await this.#store.publishNoReplace(outputFilename, outputBytes);
243
+ stagedOutput = this.#store.readExpected(outputFilename) ?? undefined;
244
+ if (
245
+ !stagedOutput ||
246
+ stagedOutput.bytes.byteLength !== generation.outputSizeBytes ||
247
+ sha256(stagedOutput.bytes) !== generation.outputSha256
248
+ ) {
249
+ throw new Error("managed_output_generation_verification_failed");
250
+ }
251
+ await this.#store.publishNoReplace(metadataFilename, metadataBytes);
252
+ stagedMetadata = this.#store.readExpected(metadataFilename) ?? undefined;
253
+ if (
254
+ !stagedMetadata ||
255
+ stagedMetadata.bytes.byteLength !== generation.metadataSizeBytes ||
256
+ sha256(stagedMetadata.bytes) !== generation.metadataSha256
257
+ ) {
258
+ throw new Error("managed_output_generation_verification_failed");
259
+ }
260
+ } catch (error) {
261
+ removeStaged();
262
+ throw error;
263
+ }
162
264
 
163
- const priorSelector = this.#store.readExpected(selectorFilename);
164
- const priorGeneration = priorSelector
165
- ? parseManagedOutputGeneration(priorSelector.bytes, outputFilenamePrefix)
166
- : null;
167
- const generationId = randomUUID();
168
- const outputFilename = `${outputFilenamePrefix}.${generationId}.output`;
169
- const metadataFilename = `${outputFilename}.meta.json`;
170
- const generation: ManagedOutputGeneration = {
171
- outputFilename,
172
- metadataFilename,
173
- outputSizeBytes: outputBytes.byteLength,
174
- outputSha256: sha256(outputBytes),
175
- metadataSizeBytes: metadataBytes.byteLength,
176
- metadataSha256: sha256(metadataBytes),
177
- };
178
-
179
- // Immutable generations are not visible until the selector is replaced.
180
- await this.#store.publishNoReplace(outputFilename, outputBytes);
181
- await this.#store.publishNoReplace(metadataFilename, metadataBytes);
182
- const stagedOutput = this.#store.readExpected(outputFilename);
183
- const stagedMetadata = this.#store.readExpected(metadataFilename);
184
- if (
185
- !stagedOutput ||
186
- !stagedMetadata ||
187
- stagedOutput.bytes.byteLength !== generation.outputSizeBytes ||
188
- stagedMetadata.bytes.byteLength !== generation.metadataSizeBytes ||
189
- sha256(stagedOutput.bytes) !== generation.outputSha256 ||
190
- sha256(stagedMetadata.bytes) !== generation.metadataSha256
191
- ) {
192
- throw new Error("managed_output_generation_verification_failed");
193
- }
194
-
195
- await this.#store.replace(selectorFilename, Buffer.from(JSON.stringify(generation), "utf8"));
196
- const publishedSelector = this.#store.readExpected(selectorFilename);
197
- const publishedGeneration = publishedSelector
198
- ? parseManagedOutputGeneration(publishedSelector.bytes, outputFilenamePrefix)
199
- : null;
200
- if (!publishedGeneration || !sameGeneration(publishedGeneration, generation)) {
201
- throw new Error("managed_output_selector_verification_failed");
202
- }
203
-
204
- // Cleanup cannot affect the selected generation or publication outcome.
205
- if (priorGeneration && !sameGeneration(priorGeneration, generation)) {
206
- for (const filename of [priorGeneration.outputFilename, priorGeneration.metadataFilename]) {
265
+ const rollbackSelector = (observed: ManagedFileSnapshot | null): boolean => {
266
+ if (!observed || !referencesGeneration(observed.bytes, generation)) return false;
207
267
  try {
208
- const previous = this.#store.readExpected(filename);
209
- if (previous) this.#store.removeExpected(filename, previous);
268
+ if (priorSelector) {
269
+ this.#store!.replaceExpected(selectorFilename, priorSelector.bytes, observed);
270
+ } else {
271
+ this.#store!.removeExpected(selectorFilename, observed);
272
+ }
273
+ return true;
210
274
  } catch {
211
- // Retain unreachable generations for a later safe cleanup.
275
+ return false;
212
276
  }
277
+ };
278
+ const selectorBytes = Buffer.from(JSON.stringify(generation), "utf8");
279
+ try {
280
+ if (priorSelector) this.#store.replaceExpected(selectorFilename, selectorBytes, priorSelector);
281
+ else await this.#store.publishNoReplace(selectorFilename, selectorBytes);
282
+ } catch (error) {
283
+ let observed: ManagedFileSnapshot | null = null;
284
+ let observedRead = false;
285
+ try {
286
+ observed = this.#store.readExpected(selectorFilename);
287
+ observedRead = true;
288
+ } catch {
289
+ // Without an exact selector snapshot, staged files remain quarantined.
290
+ }
291
+ if (observedRead && (!observed || !referencesGeneration(observed.bytes, generation))) removeStaged();
292
+ else if (rollbackSelector(observed)) removeStaged();
293
+ throw error;
213
294
  }
295
+ const publishedSelector = this.#store.readExpected(selectorFilename);
296
+ const publishedGeneration = publishedSelector
297
+ ? parseManagedOutputGeneration(publishedSelector.bytes, outputFilenamePrefix)
298
+ : null;
299
+ if (!publishedGeneration || !sameGeneration(publishedGeneration, generation)) {
300
+ if (rollbackSelector(publishedSelector)) removeStaged();
301
+ throw new Error("managed_output_selector_verification_failed");
302
+ }
303
+
304
+ // Cleanup cannot affect the selected generation or publication outcome.
305
+ if (priorGeneration && !sameGeneration(priorGeneration, generation)) {
306
+ for (const filename of [priorGeneration.outputFilename, priorGeneration.metadataFilename]) {
307
+ try {
308
+ const previous = this.#store.readExpected(filename);
309
+ if (previous) this.#store.removeExpected(filename, previous);
310
+ } catch {
311
+ // Retain unreachable generations for a later safe cleanup.
312
+ }
313
+ }
314
+ }
315
+ } finally {
316
+ await mutationFence?.release();
214
317
  }
215
318
  }
216
319
  async publishNamedNoReplace(filename: string, bytes: Uint8Array): Promise<void> {
@@ -252,8 +355,8 @@ export class ArtifactManager {
252
355
  for (const file of files) {
253
356
  // Files are named: {id}.{toolType}.log
254
357
  const match = file.match(/^(\d+)\..*\.log$/);
255
- if (match) {
256
- const id = parseInt(match[1], 10);
358
+ if (match && isValidArtifactId(match[1])) {
359
+ const id = Number(match[1]);
257
360
  if (id > maxId) maxId = id;
258
361
  }
259
362
  }
@@ -265,6 +368,9 @@ export class ArtifactManager {
265
368
  * IDs are sequential within the session.
266
369
  */
267
370
  allocateId(): number {
371
+ if (!Number.isSafeInteger(this.#nextId) || this.#nextId < 0) {
372
+ throw new Error("Artifact id space exhausted");
373
+ }
268
374
  return this.#nextId++;
269
375
  }
270
376
 
@@ -281,25 +387,28 @@ export class ArtifactManager {
281
387
  return { id, path: path.join(this.#dir, this.#filename(id, toolType)) };
282
388
  }
283
389
 
284
- /**
285
- * Save content as an artifact and return the artifact ID.
286
- * Content is written to a private temporary inode, synced, and linked into
287
- * the artifact directory only after the complete terminal payload exists.
288
- */
289
- async save(content: string, toolType: string, options: ArtifactSaveOptions = {}): Promise<string> {
390
+ /** Save content and return the artifact ID plus exact storage completeness evidence. */
391
+ async saveWithReceipt(
392
+ content: string,
393
+ toolType: string,
394
+ options: ArtifactSaveOptions = {},
395
+ ): Promise<ArtifactSaveReceipt> {
290
396
  await this.#ensureDir();
291
397
  const id = String(this.allocateId());
292
398
  const maxBytes = Math.max(0, options.maxBytes ?? DEFAULT_ARTIFACT_MAX_BYTES);
293
399
  const contentBytes = Buffer.byteLength(content, "utf-8");
294
- const published =
295
- contentBytes > maxBytes
296
- ? (() => {
297
- const truncated = truncateHeadBytes(content, maxBytes);
298
- return `${truncated.text}\n[artifact truncated after ${truncated.bytes} bytes; omitted at least ${contentBytes - truncated.bytes} bytes]\n`;
299
- })()
300
- : content;
400
+ const truncated = contentBytes > maxBytes ? truncateHeadBytes(content, maxBytes) : undefined;
401
+ const omittedBytes = truncated ? contentBytes - truncated.bytes : undefined;
402
+ const published = truncated
403
+ ? `${truncated.text}\n[artifact truncated after ${truncated.bytes} bytes; omitted at least ${omittedBytes} bytes]\n`
404
+ : content;
301
405
  await this.#publish(published, this.#filename(id, toolType));
302
- return id;
406
+ return { id, complete: omittedBytes === undefined, omittedBytes };
407
+ }
408
+
409
+ /** Save content as an artifact and return the artifact ID. */
410
+ async save(content: string, toolType: string, options: ArtifactSaveOptions = {}): Promise<string> {
411
+ return (await this.saveWithReceipt(content, toolType, options)).id;
303
412
  }
304
413
 
305
414
  /**
@@ -330,8 +439,36 @@ export class ArtifactManager {
330
439
  * @param id Artifact ID (numeric string)
331
440
  */
332
441
  async getPath(id: string): Promise<string | null> {
442
+ if (!isValidArtifactId(id)) return null;
333
443
  const files = await this.listFiles();
334
- const match = files.find(f => f.startsWith(`${id}.`));
335
- return match ? path.join(this.#dir, match) : null;
444
+ const matches = files.filter(filename => new RegExp(`^${id}\\.[a-zA-Z0-9_-]+\\.log$`, "u").test(filename));
445
+ if (matches.length !== 1) return null;
446
+ const filename = matches[0]!;
447
+ if (this.#store) {
448
+ try {
449
+ return this.#store.readExpected(filename) ? path.join(this.#dir, filename) : null;
450
+ } catch {
451
+ return null;
452
+ }
453
+ }
454
+ try {
455
+ const rootBefore = await fs.lstat(this.#dir, { bigint: true });
456
+ if (!rootBefore.isDirectory() || rootBefore.isSymbolicLink()) return null;
457
+ const artifactPath = path.join(this.#dir, filename);
458
+ const leaf = await fs.lstat(artifactPath, { bigint: true });
459
+ if (!leaf.isFile() || leaf.isSymbolicLink() || leaf.nlink !== 1n) return null;
460
+ const rootAfter = await fs.lstat(this.#dir, { bigint: true });
461
+ if (
462
+ !rootAfter.isDirectory() ||
463
+ rootAfter.isSymbolicLink() ||
464
+ rootAfter.dev !== rootBefore.dev ||
465
+ rootAfter.ino !== rootBefore.ino
466
+ ) {
467
+ return null;
468
+ }
469
+ return artifactPath;
470
+ } catch {
471
+ return null;
472
+ }
336
473
  }
337
474
  }
@@ -0,0 +1,106 @@
1
+ import type { AttemptScope, AttemptScopeAuthority } from "@gajae-code/agent-core/attempt-scope";
2
+
3
+ export type AttemptExecutionState = "unknown" | "clean" | "executed";
4
+
5
+ interface AttemptRecord {
6
+ readonly scope: AttemptScope;
7
+ state: AttemptExecutionState;
8
+ }
9
+
10
+ type RetireCapableAuthority = AttemptScopeAuthority & {
11
+ unregisterSide?: (lineage: AttemptScope["lineage"]) => void;
12
+ };
13
+
14
+ const RECORD_LRU_CAP = 1024;
15
+
16
+ function scopeKey(scope: AttemptScope): string {
17
+ return `${scope.lineage}:${scope.attemptId}:${scope.generation}`;
18
+ }
19
+
20
+ /**
21
+ * Tracks whether an attempt has had an extension handler delivered to it.
22
+ *
23
+ * Records are deliberately established in two phases: allocation registers an
24
+ * `unknown` record, then the session marks it `clean` immediately before the
25
+ * first handler-capable delivery can occur. The runner changes a clean record
26
+ * to `executed` immediately before invoking its first handler.
27
+ */
28
+ export class AttemptRecordStore {
29
+ readonly #authority: RetireCapableAuthority;
30
+ readonly #records = new Map<string, AttemptRecord>();
31
+
32
+ constructor(authority: AttemptScopeAuthority) {
33
+ this.#authority = authority;
34
+ }
35
+
36
+ register(scope: AttemptScope): boolean {
37
+ if (!this.#authority.isCurrent(scope)) return false;
38
+ const key = scopeKey(scope);
39
+ const existing = this.#records.get(key);
40
+ if (existing) {
41
+ this.#touch(key, existing);
42
+ return true;
43
+ }
44
+ this.#records.set(key, { scope, state: "unknown" });
45
+ this.#evictIfNeeded();
46
+ return true;
47
+ }
48
+
49
+ establishClean(scope: AttemptScope): boolean {
50
+ if (!this.#authority.isCurrent(scope)) return false;
51
+ const key = scopeKey(scope);
52
+ const record = this.#records.get(key);
53
+ if (!record) return false;
54
+ if (record.state === "unknown") record.state = "clean";
55
+ if (record.state === "clean") this.#touch(key, record);
56
+ return record.state === "clean";
57
+ }
58
+
59
+ markExecuted(scope: AttemptScope): boolean {
60
+ if (!this.#authority.isCurrent(scope)) return false;
61
+ const key = scopeKey(scope);
62
+ const record = this.#records.get(key);
63
+ if (!record) return false;
64
+ if (record.state === "executed") {
65
+ this.#touch(key, record);
66
+ return true;
67
+ }
68
+ if (record.state !== "clean") return false;
69
+ record.state = "executed";
70
+ this.#touch(key, record);
71
+ return true;
72
+ }
73
+
74
+ isClean(scope: AttemptScope): boolean {
75
+ if (!this.#authority.isCurrent(scope)) return false;
76
+ return this.#records.get(scopeKey(scope))?.state === "clean";
77
+ }
78
+
79
+ retire(scope: AttemptScope): boolean {
80
+ const key = scopeKey(scope);
81
+ const deleted = this.#records.delete(key);
82
+ if (scope.lineage !== "main") this.#authority.unregisterSide?.(scope.lineage);
83
+ return deleted;
84
+ }
85
+
86
+ #touch(key: string, record: AttemptRecord): void {
87
+ this.#records.delete(key);
88
+ this.#records.set(key, record);
89
+ }
90
+
91
+ #evictIfNeeded(): void {
92
+ while (this.#records.size > RECORD_LRU_CAP) {
93
+ const oldest = this.#records.keys().next().value as string | undefined;
94
+ if (oldest === undefined) return;
95
+ const record = this.#records.get(oldest);
96
+ if (!record) {
97
+ this.#records.delete(oldest);
98
+ continue;
99
+ }
100
+ // Never evict a record whose scope is still current (live attempt).
101
+ if (this.#authority.isCurrent(record.scope)) break;
102
+ this.#records.delete(oldest);
103
+ if (record.scope.lineage !== "main") this.#authority.unregisterSide?.(record.scope.lineage);
104
+ }
105
+ }
106
+ }
@@ -56,18 +56,31 @@ export interface ClientBridgeTerminalOutput {
56
56
 
57
57
  export interface ClientBridgeTerminalHandle {
58
58
  terminalId: string;
59
+ /** Exact provider lease/session identity that owns this terminal. */
60
+ providerLeaseId: string;
59
61
  waitForExit(): Promise<ClientBridgeTerminalExitStatus>;
60
62
  currentOutput(): Promise<ClientBridgeTerminalOutput>;
61
63
  kill(): Promise<void>;
62
64
  release(): Promise<void>;
65
+ /** Full authenticated provider identity, when supplied by the transport. */
66
+ providerLeaseIdentity?: ClientBridgeProviderLeaseIdentity;
67
+ }
68
+
69
+ export interface ClientBridgeProviderLeaseIdentity {
70
+ leaseId: string;
71
+ connectionId: string;
72
+ connectionGeneration: number;
73
+ fence: string;
63
74
  }
64
75
 
65
76
  export interface ClientBridgeCreateTerminalParams {
77
+ toolCallId: string;
66
78
  command: string;
67
79
  args?: string[];
68
80
  env?: Array<{ name: string; value: string }>;
69
81
  cwd?: string;
70
82
  outputByteLimit?: number;
83
+ signal?: AbortSignal;
71
84
  }
72
85
 
73
86
  export interface ClientBridge {
@@ -77,6 +90,8 @@ export interface ClientBridge {
77
90
  readTextFile?(params: { path: string; line?: number; limit?: number }): Promise<string>;
78
91
  writeTextFile?(params: { path: string; content: string }): Promise<void>;
79
92
  createTerminal?(params: ClientBridgeCreateTerminalParams): Promise<ClientBridgeTerminalHandle>;
93
+ /** Fence the exact provider identity after cleanup ownership becomes uncertain. */
94
+ quarantineProviderLease?(providerLease: string | ClientBridgeProviderLeaseIdentity, reason: string): Promise<void>;
80
95
  requestPermission?(
81
96
  toolCall: ClientBridgePermissionToolCall,
82
97
  options: ClientBridgePermissionOption[],