@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.
- package/CHANGELOG.md +32 -0
- package/README.md +4 -1
- package/dist/types/eval/backend.d.ts +2 -2
- package/dist/types/exec/bash-executor.d.ts +10 -8
- package/dist/types/extensibility/extensions/runner.d.ts +9 -7
- package/dist/types/gjc-runtime/launch-tmux.d.ts +22 -3
- package/dist/types/gjc-runtime/psmux-detect.d.ts +3 -0
- package/dist/types/gjc-runtime/team-launch.d.ts +3 -1
- package/dist/types/gjc-runtime/team-runtime.d.ts +14 -2
- package/dist/types/gjc-runtime/team-store.d.ts +0 -4
- package/dist/types/gjc-runtime/team-workers.d.ts +5 -0
- package/dist/types/gjc-runtime/tmux-common.d.ts +1 -0
- package/dist/types/gjc-runtime/tmux-provider-context.d.ts +57 -0
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +8 -1
- package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +3 -0
- package/dist/types/gjc-runtime/ultragoal-review-source.d.ts +83 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +57 -4
- package/dist/types/internal-urls/registry-helpers.d.ts +18 -0
- package/dist/types/modes/components/gjc-bundle-settings.d.ts +1 -0
- package/dist/types/modes/components/plugin-settings.d.ts +2 -0
- package/dist/types/modes/components/settings-selector.d.ts +2 -0
- package/dist/types/sdk/broker/broker.d.ts +18 -0
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +13 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +1 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +4 -3
- package/dist/types/sdk/bus/index.d.ts +4 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +4 -2
- package/dist/types/sdk/bus/telegram-daemon.d.ts +25 -3
- package/dist/types/sdk/bus/topic-registry.d.ts +63 -3
- package/dist/types/sdk/host/host.d.ts +23 -2
- package/dist/types/sdk/host/reverse-leases.d.ts +7 -4
- package/dist/types/session/agent-session.d.ts +5 -4
- package/dist/types/session/artifacts.d.ts +8 -5
- package/dist/types/session/attempt-record-store.d.ts +19 -0
- package/dist/types/session/client-bridge.d.ts +14 -0
- package/dist/types/session/internal/managed-session-storage.d.ts +5 -2
- package/dist/types/session/messages.d.ts +4 -1
- package/dist/types/session/session-manager.d.ts +6 -4
- package/dist/types/session/session-storage.d.ts +8 -0
- package/dist/types/session/streaming-output.d.ts +32 -0
- package/dist/types/task/receipt.d.ts +2 -0
- package/dist/types/task/types.d.ts +178 -0
- package/dist/types/tools/bash.d.ts +1 -1
- package/dist/types/tools/fetch.d.ts +3 -0
- package/dist/types/tools/output-meta.d.ts +37 -5
- package/dist/types/tools/tool-result.d.ts +1 -0
- package/dist/types/utils/artifact-id.d.ts +2 -0
- package/package.json +8 -8
- package/scripts/capture-sticky-viewport-showcase.ts +131 -5
- package/scripts/verify-gjc-sdk-canonicalization.ts +128 -183
- package/scripts/verify-sticky-viewport-showcase.ts +620 -93
- package/src/config/model-profiles.ts +8 -0
- package/src/config/settings-schema.ts +17 -3
- package/src/cursor.ts +20 -13
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +5 -2
- package/src/eval/backend.ts +2 -2
- package/src/exec/bash-executor.ts +425 -151
- package/src/extensibility/extensions/runner.ts +80 -6
- package/src/extensibility/extensions/wrapper.ts +22 -15
- package/src/extensibility/runtime-skill-discovery.ts +4 -6
- package/src/gjc-runtime/launch-tmux.ts +309 -293
- package/src/gjc-runtime/managed-owner-supervisor.ts +6 -2
- package/src/gjc-runtime/psmux-detect.ts +52 -6
- package/src/gjc-runtime/team-launch.ts +50 -2
- package/src/gjc-runtime/team-runtime.ts +716 -251
- package/src/gjc-runtime/team-store.ts +25 -7
- package/src/gjc-runtime/team-workers.ts +161 -1
- package/src/gjc-runtime/tmux-common.ts +16 -4
- package/src/gjc-runtime/tmux-gc.ts +37 -5
- package/src/gjc-runtime/tmux-owner-isolation.ts +2 -0
- package/src/gjc-runtime/tmux-provider-context.ts +451 -0
- package/src/gjc-runtime/tmux-sessions.ts +409 -156
- package/src/gjc-runtime/ultragoal-change-set.ts +90 -44
- package/src/gjc-runtime/ultragoal-receipt-freshness.ts +7 -0
- package/src/gjc-runtime/ultragoal-review-source.ts +378 -0
- package/src/gjc-runtime/ultragoal-runtime.ts +640 -159
- package/src/gjc-runtime/workflow-manifest.generated.json +40 -1
- package/src/gjc-runtime/workflow-manifest.ts +12 -0
- package/src/internal-urls/agent-protocol.ts +98 -38
- package/src/internal-urls/artifact-protocol.ts +28 -17
- package/src/internal-urls/docs-index.generated.ts +5 -5
- package/src/internal-urls/local-protocol.ts +39 -18
- package/src/internal-urls/registry-helpers.ts +62 -0
- package/src/modes/acp/acp-agent.ts +51 -13
- package/src/modes/acp/acp-client-bridge.ts +154 -11
- package/src/modes/components/gjc-bundle-settings.ts +2 -0
- package/src/modes/components/plugin-settings.ts +5 -0
- package/src/modes/components/settings-selector.ts +4 -0
- package/src/modes/controllers/selector-controller.ts +1 -0
- package/src/prompts/tools/bash.md +2 -2
- package/src/prompts/tools/monitor.md +4 -4
- package/src/sdk/acp/adapter.ts +200 -9
- package/src/sdk/broker/broker.ts +93 -10
- package/src/sdk/broker/lifecycle-ledger.ts +227 -11
- package/src/sdk/broker/lifecycle.ts +691 -92
- package/src/sdk/bus/chat-daemon-control.ts +32 -6
- package/src/sdk/bus/index.ts +347 -14
- package/src/sdk/bus/telegram-daemon-contract.ts +4 -2
- package/src/sdk/bus/telegram-daemon.ts +283 -44
- package/src/sdk/bus/topic-registry.ts +133 -10
- package/src/sdk/host/host.ts +187 -9
- package/src/sdk/host/reverse-leases.ts +249 -42
- package/src/sdk/session.ts +7 -6
- package/src/session/agent-session.ts +930 -354
- package/src/session/artifacts.ts +211 -74
- package/src/session/attempt-record-store.ts +106 -0
- package/src/session/client-bridge.ts +15 -0
- package/src/session/internal/managed-session-scope.ts +252 -67
- package/src/session/internal/managed-session-storage.ts +105 -5
- package/src/session/messages.ts +32 -7
- package/src/session/session-manager.ts +19 -21
- package/src/session/session-storage.ts +146 -24
- package/src/session/streaming-output.ts +198 -40
- package/src/task/index.ts +45 -0
- package/src/task/provider-retry-status.ts +4 -1
- package/src/task/receipt.ts +4 -0
- package/src/task/render.ts +30 -0
- package/src/task/types.ts +34 -0
- package/src/tools/bash.ts +818 -239
- package/src/tools/eval.ts +99 -4
- package/src/tools/fetch.ts +77 -15
- package/src/tools/find.ts +7 -3
- package/src/tools/monitor.ts +7 -1
- package/src/tools/output-meta.ts +706 -116
- package/src/tools/search.ts +9 -4
- package/src/tools/tool-result.ts +1 -0
- package/src/utils/artifact-id.ts +6 -0
package/src/session/artifacts.ts
CHANGED
|
@@ -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
|
|
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 (
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
|
335
|
-
|
|
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[],
|