@gajae-code/coding-agent 0.5.1 → 0.5.3
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 +31 -0
- package/README.md +1 -1
- package/dist/types/async/job-manager.d.ts +6 -0
- package/dist/types/cli/setup-cli.d.ts +8 -1
- package/dist/types/commands/setup.d.ts +7 -0
- package/dist/types/config/file-lock.d.ts +24 -2
- package/dist/types/config/model-registry.d.ts +4 -0
- package/dist/types/config/models-config-schema.d.ts +5 -0
- package/dist/types/config/settings-schema.d.ts +62 -0
- package/dist/types/dap/client.d.ts +2 -1
- package/dist/types/edit/read-file.d.ts +6 -0
- package/dist/types/eval/js/context-manager.d.ts +3 -0
- package/dist/types/eval/js/executor.d.ts +1 -0
- package/dist/types/exec/bash-executor.d.ts +2 -0
- package/dist/types/gjc-runtime/state-writer.d.ts +64 -2
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +7 -1
- package/dist/types/gjc-runtime/ultragoal-guard.d.ts +10 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +29 -0
- package/dist/types/lsp/types.d.ts +2 -0
- package/dist/types/modes/bridge/bridge-mode.d.ts +1 -0
- package/dist/types/modes/components/model-selector.d.ts +2 -0
- package/dist/types/modes/components/oauth-selector.d.ts +1 -0
- package/dist/types/modes/components/provider-onboarding-selector.d.ts +1 -1
- package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +1 -0
- package/dist/types/modes/components/tool-execution.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +1 -1
- package/dist/types/modes/rpc/rpc-mode.d.ts +56 -1
- package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +10 -0
- package/dist/types/modes/theme/defaults/index.d.ts +302 -0
- package/dist/types/modes/theme/theme.d.ts +1 -0
- package/dist/types/modes/types.d.ts +1 -1
- package/dist/types/runtime/process-lifecycle.d.ts +108 -0
- package/dist/types/runtime-mcp/transports/stdio.d.ts +1 -0
- package/dist/types/runtime-mcp/types.d.ts +2 -0
- package/dist/types/session/agent-session.d.ts +17 -1
- package/dist/types/session/artifacts.d.ts +4 -1
- package/dist/types/session/history-storage.d.ts +2 -2
- package/dist/types/session/session-manager.d.ts +10 -1
- package/dist/types/session/streaming-output.d.ts +5 -0
- package/dist/types/setup/credential-import.d.ts +79 -0
- package/dist/types/slash-commands/helpers/fast-status-report.d.ts +76 -0
- package/dist/types/task/executor.d.ts +1 -0
- package/dist/types/task/render.d.ts +1 -1
- package/dist/types/tools/bash.d.ts +1 -0
- package/dist/types/tools/browser/tab-supervisor.d.ts +9 -0
- package/dist/types/tools/sqlite-reader.d.ts +2 -1
- package/dist/types/tools/subagent-render.d.ts +7 -1
- package/dist/types/tools/subagent.d.ts +21 -0
- package/dist/types/tools/ultragoal-ask-guard.d.ts +5 -0
- package/dist/types/web/search/index.d.ts +4 -4
- package/dist/types/web/search/provider.d.ts +16 -20
- package/dist/types/web/search/providers/base.d.ts +2 -1
- package/dist/types/web/search/providers/openai-compatible.d.ts +9 -0
- package/dist/types/web/search/types.d.ts +14 -2
- package/package.json +7 -7
- package/scripts/build-binary.ts +7 -0
- package/src/async/job-manager.ts +153 -39
- package/src/cli/args.ts +2 -0
- package/src/cli/fast-help.ts +2 -0
- package/src/cli/setup-cli.ts +138 -3
- package/src/commands/setup.ts +5 -1
- package/src/commands/ultragoal.ts +3 -1
- package/src/config/file-lock-gc.ts +14 -2
- package/src/config/file-lock.ts +63 -13
- package/src/config/model-profile-activation.ts +15 -3
- package/src/config/model-profiles.ts +15 -15
- package/src/config/model-registry.ts +21 -1
- package/src/config/models-config-schema.ts +1 -0
- package/src/config/settings-schema.ts +62 -0
- package/src/dap/client.ts +105 -64
- package/src/dap/session.ts +44 -7
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +30 -8
- package/src/edit/read-file.ts +19 -1
- package/src/eval/js/context-manager.ts +228 -65
- package/src/eval/js/executor.ts +2 -0
- package/src/eval/js/index.ts +1 -0
- package/src/eval/js/worker-core.ts +10 -6
- package/src/eval/py/executor.ts +68 -19
- package/src/eval/py/kernel.ts +46 -22
- package/src/eval/py/runner.py +68 -14
- package/src/exec/bash-executor.ts +49 -13
- package/src/gjc-runtime/deep-interview-recorder.ts +40 -0
- package/src/gjc-runtime/launch-tmux.ts +3 -4
- package/src/gjc-runtime/ralplan-runtime.ts +174 -12
- package/src/gjc-runtime/state-runtime.ts +2 -1
- package/src/gjc-runtime/state-writer.ts +254 -7
- package/src/gjc-runtime/tmux-gc.ts +88 -38
- package/src/gjc-runtime/tmux-sessions.ts +44 -6
- package/src/gjc-runtime/ultragoal-guard.ts +155 -0
- package/src/gjc-runtime/ultragoal-runtime.ts +1227 -31
- package/src/gjc-runtime/workflow-manifest.generated.json +44 -0
- package/src/gjc-runtime/workflow-manifest.ts +12 -0
- package/src/harness-control-plane/owner.ts +3 -2
- package/src/harness-control-plane/rpc-adapter.ts +1 -1
- package/src/hooks/skill-state.ts +121 -2
- package/src/internal-urls/artifact-protocol.ts +10 -1
- package/src/internal-urls/docs-index.generated.ts +14 -10
- package/src/lsp/client.ts +64 -26
- package/src/lsp/defaults.json +1 -0
- package/src/lsp/index.ts +2 -1
- package/src/lsp/lspmux.ts +33 -9
- package/src/lsp/types.ts +2 -0
- package/src/main.ts +14 -4
- package/src/modes/acp/acp-agent.ts +4 -2
- package/src/modes/bridge/bridge-mode.ts +23 -1
- package/src/modes/components/assistant-message.ts +10 -2
- package/src/modes/components/bash-execution.ts +5 -1
- package/src/modes/components/eval-execution.ts +5 -1
- package/src/modes/components/history-search.ts +5 -2
- package/src/modes/components/model-selector.ts +60 -2
- package/src/modes/components/oauth-selector.ts +5 -0
- package/src/modes/components/provider-onboarding-selector.ts +6 -1
- package/src/modes/components/runtime-mcp-add-wizard.ts +58 -7
- package/src/modes/components/skill-message.ts +24 -16
- package/src/modes/components/tool-execution.ts +6 -0
- package/src/modes/controllers/extension-ui-controller.ts +33 -6
- package/src/modes/controllers/input-controller.ts +5 -0
- package/src/modes/controllers/selector-controller.ts +86 -2
- package/src/modes/interactive-mode.ts +11 -1
- package/src/modes/rpc/rpc-mode.ts +132 -18
- package/src/modes/shared/agent-wire/command-dispatch.ts +5 -2
- package/src/modes/shared/agent-wire/host-tool-bridge.ts +3 -0
- package/src/modes/shared/agent-wire/unattended-session.ts +16 -1
- package/src/modes/theme/defaults/claude-code.json +100 -0
- package/src/modes/theme/defaults/codex.json +100 -0
- package/src/modes/theme/defaults/index.ts +6 -0
- package/src/modes/theme/defaults/opencode.json +102 -0
- package/src/modes/theme/theme.ts +2 -2
- package/src/modes/types.ts +1 -1
- package/src/modes/utils/ui-helpers.ts +5 -2
- package/src/prompts/agents/executor.md +5 -2
- package/src/runtime/process-lifecycle.ts +400 -0
- package/src/runtime-mcp/manager.ts +164 -50
- package/src/runtime-mcp/transports/http.ts +12 -11
- package/src/runtime-mcp/transports/stdio.ts +64 -38
- package/src/runtime-mcp/types.ts +3 -0
- package/src/sdk.ts +39 -1
- package/src/session/agent-session.ts +190 -33
- package/src/session/artifacts.ts +17 -2
- package/src/session/blob-store.ts +36 -2
- package/src/session/history-storage.ts +32 -11
- package/src/session/session-manager.ts +99 -31
- package/src/session/streaming-output.ts +54 -3
- package/src/setup/credential-import.ts +429 -0
- package/src/skill-state/deep-interview-mutation-guard.ts +2 -1
- package/src/slash-commands/builtin-registry.ts +30 -3
- package/src/slash-commands/helpers/fast-status-report.ts +111 -0
- package/src/task/executor.ts +7 -1
- package/src/task/render.ts +18 -7
- package/src/tools/archive-reader.ts +10 -1
- package/src/tools/ask.ts +4 -2
- package/src/tools/bash.ts +11 -4
- package/src/tools/browser/tab-supervisor.ts +22 -0
- package/src/tools/browser.ts +38 -4
- package/src/tools/cron.ts +1 -1
- package/src/tools/read.ts +11 -12
- package/src/tools/sqlite-reader.ts +19 -5
- package/src/tools/subagent-render.ts +119 -29
- package/src/tools/subagent.ts +147 -7
- package/src/tools/ultragoal-ask-guard.ts +39 -0
- package/src/web/search/index.ts +25 -25
- package/src/web/search/provider.ts +178 -87
- package/src/web/search/providers/base.ts +2 -1
- package/src/web/search/providers/openai-compatible.ts +151 -0
- package/src/web/search/types.ts +47 -22
package/src/dap/session.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
2
|
import * as timers from "node:timers/promises";
|
|
3
|
-
import { logger,
|
|
3
|
+
import { logger, untilAborted } from "@gajae-code/utils";
|
|
4
4
|
import { NON_INTERACTIVE_ENV } from "../exec/non-interactive-env";
|
|
5
|
+
import { type OwnedProcess, spawnOwnedProcess } from "../runtime/process-lifecycle";
|
|
5
6
|
import { DapClient } from "./client";
|
|
6
7
|
import type {
|
|
7
8
|
DapAttachArguments,
|
|
@@ -63,6 +64,24 @@ import type {
|
|
|
63
64
|
DapWriteMemoryResponse,
|
|
64
65
|
} from "./types";
|
|
65
66
|
|
|
67
|
+
function drainStream(stream: ReadableStream<Uint8Array> | null | undefined): void {
|
|
68
|
+
if (!stream) return;
|
|
69
|
+
void (async () => {
|
|
70
|
+
try {
|
|
71
|
+
const reader = stream.getReader();
|
|
72
|
+
try {
|
|
73
|
+
while (!(await reader.read()).done) {
|
|
74
|
+
// drain only
|
|
75
|
+
}
|
|
76
|
+
} finally {
|
|
77
|
+
reader.releaseLock();
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
// Process stream closed or was already consumed.
|
|
81
|
+
}
|
|
82
|
+
})();
|
|
83
|
+
}
|
|
84
|
+
|
|
66
85
|
interface DapSession {
|
|
67
86
|
id: string;
|
|
68
87
|
adapter: DapResolvedAdapter;
|
|
@@ -87,6 +106,7 @@ interface DapSession {
|
|
|
87
106
|
initializedSeen: boolean;
|
|
88
107
|
needsConfigurationDone: boolean;
|
|
89
108
|
configurationDoneSent: boolean;
|
|
109
|
+
runInTerminalProcesses: Set<OwnedProcess>;
|
|
90
110
|
}
|
|
91
111
|
|
|
92
112
|
export interface DapOutputSnapshot {
|
|
@@ -948,6 +968,7 @@ export class DapSessionManager {
|
|
|
948
968
|
initializedSeen: false,
|
|
949
969
|
needsConfigurationDone: false,
|
|
950
970
|
configurationDoneSent: false,
|
|
971
|
+
runInTerminalProcesses: new Set(),
|
|
951
972
|
};
|
|
952
973
|
client.onReverseRequest("runInTerminal", async rawArgs => {
|
|
953
974
|
const args = (rawArgs ?? {}) as DapRunInTerminalArguments;
|
|
@@ -957,17 +978,21 @@ export class DapSessionManager {
|
|
|
957
978
|
const env = Object.fromEntries(
|
|
958
979
|
Object.entries(args.env ?? {}).filter((entry): entry is [string, string] => entry[1] !== null),
|
|
959
980
|
);
|
|
960
|
-
const
|
|
981
|
+
const owner = spawnOwnedProcess(args.args, {
|
|
961
982
|
cwd: args.cwd ?? session.cwd,
|
|
962
|
-
stdin: "
|
|
983
|
+
stdin: "ignore",
|
|
963
984
|
env: {
|
|
964
985
|
...Bun.env,
|
|
965
986
|
...NON_INTERACTIVE_ENV,
|
|
966
987
|
...env,
|
|
967
988
|
},
|
|
968
|
-
|
|
989
|
+
name: `dap:${session.id}:runInTerminal`,
|
|
969
990
|
});
|
|
970
|
-
|
|
991
|
+
drainStream(owner.child.stdout);
|
|
992
|
+
drainStream(owner.child.stderr);
|
|
993
|
+
session.runInTerminalProcesses.add(owner);
|
|
994
|
+
owner.exited.finally(() => session.runInTerminalProcesses.delete(owner));
|
|
995
|
+
return { processId: owner.pid } satisfies DapRunInTerminalResponse;
|
|
971
996
|
});
|
|
972
997
|
client.onReverseRequest("startDebugging", async rawArgs => {
|
|
973
998
|
const startArgs = (rawArgs ?? {}) as Partial<DapStartDebuggingArguments>;
|
|
@@ -1294,12 +1319,24 @@ export class DapSessionManager {
|
|
|
1294
1319
|
return session;
|
|
1295
1320
|
}
|
|
1296
1321
|
|
|
1297
|
-
#disposeSession(session: DapSession) {
|
|
1322
|
+
async #disposeSession(session: DapSession) {
|
|
1298
1323
|
if (this.#activeSessionId === session.id) {
|
|
1299
1324
|
this.#activeSessionId = null;
|
|
1300
1325
|
}
|
|
1301
1326
|
this.#sessions.delete(session.id);
|
|
1302
|
-
|
|
1327
|
+
await this.#disposeRunInTerminalProcesses(session);
|
|
1328
|
+
await session.client.dispose().catch(() => {});
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
async #disposeRunInTerminalProcesses(session: DapSession): Promise<void> {
|
|
1332
|
+
const owners = [...session.runInTerminalProcesses];
|
|
1333
|
+
session.runInTerminalProcesses.clear();
|
|
1334
|
+
await Promise.allSettled(
|
|
1335
|
+
owners.map(async owner => {
|
|
1336
|
+
await owner.dispose();
|
|
1337
|
+
await owner.awaitExit({ timeoutMs: 1_000 });
|
|
1338
|
+
}),
|
|
1339
|
+
);
|
|
1303
1340
|
}
|
|
1304
1341
|
}
|
|
1305
1342
|
|
|
@@ -191,10 +191,10 @@ An ultragoal story cannot be checkpointed `complete` until the active agent has
|
|
|
191
191
|
- code-side: maintainability, tests, integration points, and unsafe shortcuts.
|
|
192
192
|
5. Delegate an `executor` QA/red-team lane to build and run the e2e/read-teaming QA suite appropriate for the story. This lane must try to break the change, not just confirm the happy path. It must start from the approved plan/spec/acceptance criteria, then user-facing contracts, and only then implementation code as supporting evidence. Plan/code mismatches are blockers, not items to paper over with implementation intent.
|
|
193
193
|
6. The executor QA/red-team lane must prove evidence by the real surface under test:
|
|
194
|
-
- GUI/web surfaces require
|
|
195
|
-
- CLI surfaces require
|
|
196
|
-
-
|
|
197
|
-
-
|
|
194
|
+
- GUI/web surfaces require a valid automation transcript plus a non-uniform screenshot. Bare `inlineEvidence` text or typed receipts never prove live GUI/web execution.
|
|
195
|
+
- CLI surfaces require runtime argv replay: `replaySafe: true`, an allowlisted argv `command`, and replayed normalized stdout matching `recordedStdout`; unsafe commands require audited `replayExempt` metadata plus a structurally valid fallback artifact.
|
|
196
|
+
- Native/desktop/tui surfaces require a structurally valid screenshot, PTY capture with terminal control codes, or app-automation transcript.
|
|
197
|
+
- API/package/algorithm/math surfaces require a real artifact file or typed receipt. Bare `inlineEvidence` text alone is not sufficient for any surface.
|
|
198
198
|
7. The executor QA/red-team lane must report a matrix using `executorQa.contractCoverage`, `executorQa.surfaceEvidence`, `executorQa.adversarialCases`, and `executorQa.artifactRefs`. Not-applicable rows are allowed only in `contractCoverage` and `surfaceEvidence`; each `status: "not_applicable"` row requires `contractRef` plus `reason`. `adversarialCases` rows cannot be not-applicable.
|
|
199
199
|
8. Run a final code review pass and fold it into the strict quality gate. Clean means `architectReview.architectureStatus`, `architectReview.productStatus`, and `architectReview.codeStatus` are all `"CLEAR"`, `architectReview.recommendation` is `"APPROVE"`, executor QA statuses are `"passed"`, iteration is `"passed"` with `fullRerun: true`, every evidence field is non-empty, every required matrix row is present, and every blockers array is empty. `COMMENT`, `WATCH`, `REQUEST CHANGES`, `BLOCK`, missing evidence, missing or shallow matrix rows, plan/code mismatches, or non-empty blockers are non-clean.
|
|
200
200
|
9. If any lane finds an issue, do **not** checkpoint `complete` and do **not** call `goal({"op":"complete"})`. Record durable blocker work instead:
|
|
@@ -204,6 +204,8 @@ An ultragoal story cannot be checkpointed `complete` until the active agent has
|
|
|
204
204
|
10. Complete or steer through the blocker story, then rerun the full blocking verification loop. Repeat until all verifier lanes are clean.
|
|
205
205
|
11. Only after the loop is clean, checkpoint the story as complete with a structured quality gate and a fresh active `goal({"op":"get"})` snapshot. The checkpoint creates a receipt; `goals.json.status` alone is not proof. In aggregate mode, the final aggregate receipt must exist before `goal({"op":"complete"})` is allowed.
|
|
206
206
|
|
|
207
|
+
While an Ultragoal run is active, the `ask` tool is blocked for all agents. Record unresolved review decisions as durable blockers with `gjc ultragoal record-review-blockers` instead of prompting interactively.
|
|
208
|
+
|
|
207
209
|
The native `checkpoint --status complete` command rejects missing or shallow gates. `--quality-gate-json` must include:
|
|
208
210
|
|
|
209
211
|
```json
|
|
@@ -229,13 +231,19 @@ The native `checkpoint --status complete` command rejects missing or shallow gat
|
|
|
229
231
|
"id": "browser-run",
|
|
230
232
|
"kind": "browser-automation",
|
|
231
233
|
"path": "artifacts/browser-run.json",
|
|
232
|
-
"description": "
|
|
234
|
+
"description": "valid automation transcript with actions, monotonic timestamps, and selectors"
|
|
233
235
|
},
|
|
234
236
|
{
|
|
235
237
|
"id": "gui-screenshot",
|
|
236
238
|
"kind": "screenshot",
|
|
237
239
|
"path": "artifacts/gui-screenshot.png",
|
|
238
|
-
"description": "screenshot
|
|
240
|
+
"description": "non-uniform screenshot evidence for the GUI/web result"
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
"id": "cli-replay",
|
|
244
|
+
"kind": "command-replay",
|
|
245
|
+
"path": "artifacts/cli-replay.json",
|
|
246
|
+
"description": "artifact file containing argv-only CLI replay JSON: schemaVersion 1, kind cli-replay, replaySafe true, allowlisted command, recordedStdout"
|
|
239
247
|
},
|
|
240
248
|
{
|
|
241
249
|
"id": "adversarial-report",
|
|
@@ -265,15 +273,23 @@ The native `checkpoint --status complete` command rejects missing or shallow gat
|
|
|
265
273
|
{
|
|
266
274
|
"id": "surface-gui",
|
|
267
275
|
"contractRef": "user-facing surface or public interface under test",
|
|
268
|
-
"surface": "gui|web|cli|api|package|algorithm|math",
|
|
276
|
+
"surface": "gui|web|cli|api|package|algorithm|math|native|desktop|tui",
|
|
269
277
|
"invocation": "real browser action, CLI command, API/package consumer call, or algorithm/property check",
|
|
270
278
|
"verdict": "passed",
|
|
271
279
|
"artifactRefs": ["browser-run", "gui-screenshot"]
|
|
272
280
|
},
|
|
281
|
+
{
|
|
282
|
+
"id": "surface-cli",
|
|
283
|
+
"contractRef": "CLI or command-line interface under test",
|
|
284
|
+
"surface": "cli",
|
|
285
|
+
"invocation": "argv replay executed by the Ultragoal runtime",
|
|
286
|
+
"verdict": "passed",
|
|
287
|
+
"artifactRefs": ["cli-replay"]
|
|
288
|
+
},
|
|
273
289
|
{
|
|
274
290
|
"id": "surface-out-of-scope",
|
|
275
291
|
"contractRef": "surface intentionally outside this story",
|
|
276
|
-
"surface": "gui|web|cli|api|package|algorithm|math",
|
|
292
|
+
"surface": "gui|web|cli|api|package|algorithm|math|native|desktop|tui",
|
|
277
293
|
"status": "not_applicable",
|
|
278
294
|
"reason": "why this surface does not apply to the current story"
|
|
279
295
|
}
|
|
@@ -300,6 +316,12 @@ The native `checkpoint --status complete` command rejects missing or shallow gat
|
|
|
300
316
|
}
|
|
301
317
|
```
|
|
302
318
|
|
|
319
|
+
For CLI replay artifacts, the JSON at `path` must be an object like `{"schemaVersion":1,"kind":"cli-replay","replaySafe":true,"command":["bun","-e","console.log(\"ultragoal-cli-ok\")"],"recordedStdout":"ultragoal-cli-ok\n"}`. Use `replayExempt` only for audited unsafe/non-deterministic invocations, with a substantive reason, approver, and same-surface fallback artifacts.
|
|
320
|
+
|
|
321
|
+
## Review mode
|
|
322
|
+
|
|
323
|
+
`gjc ultragoal review` runs the same hardened gate against an already implemented PR, branch, or worktree. Use `--pr <number>` for a PR, `--branch <ref>` for a branch diff, omit both for the current worktree, and pass `--spec <path>` when a real contract exists. `--mode review-only` emits the verdict/findings without creating fix work; `--mode review-start` records review blockers for follow-up. Review mode validates the same `executorQa` shape and live-surface artifacts as `checkpoint --status complete`. A thin or derived-only contract can never clean-pass: the verdict is capped at `inconclusive: weak-contract` until a supplied spec or equivalent strong acceptance criteria are available.
|
|
324
|
+
|
|
303
325
|
Receipts are freshness-scoped:
|
|
304
326
|
- Per-goal receipts remain fresh for their target goal unless that goal, its blocker metadata, or its supersession metadata changes.
|
|
305
327
|
- Normal later `goal_started` or clean receipt-backed `goal_checkpointed` events for other goals do not stale older per-goal receipts.
|
package/src/edit/read-file.ts
CHANGED
|
@@ -7,10 +7,28 @@
|
|
|
7
7
|
import { isEnoent } from "@gajae-code/utils";
|
|
8
8
|
import { isNotebookPath, readEditableNotebookText, serializeEditedNotebookText } from "./notebook";
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Max byte size of a file the edit modes will load whole. Editing loads + normalizes +
|
|
12
|
+
* fuzzy-matches + diffs the entire file on the main thread, so a multi-MB/generated file
|
|
13
|
+
* would block the event loop (F19). Above this, fail fast with an actionable error.
|
|
14
|
+
*/
|
|
15
|
+
export const MAX_EDIT_FILE_BYTES = 8 * 1024 * 1024;
|
|
16
|
+
|
|
10
17
|
export async function readEditFileText(absolutePath: string, path: string): Promise<string> {
|
|
11
18
|
try {
|
|
19
|
+
const file = Bun.file(absolutePath);
|
|
20
|
+
const size = file.size; // 0 for a missing file; the read below then throws ENOENT.
|
|
21
|
+
if (size > MAX_EDIT_FILE_BYTES) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
`File too large to edit safely: ${path} is ${size} bytes (limit ${MAX_EDIT_FILE_BYTES}). ` +
|
|
24
|
+
`Editing loads and diffs the whole file on the main thread; make a more targeted change, ` +
|
|
25
|
+
`split the file, or use a specialized tool.`,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
// Guard BEFORE the notebook fast-path: a >8 MiB .ipynb would otherwise load + JSON-parse
|
|
29
|
+
// + convert the whole file via readEditableNotebookText, bypassing the F19 freeze guard.
|
|
12
30
|
if (isNotebookPath(absolutePath)) return await readEditableNotebookText(absolutePath, path);
|
|
13
|
-
return await
|
|
31
|
+
return await file.text();
|
|
14
32
|
} catch (error) {
|
|
15
33
|
if (isEnoent(error)) {
|
|
16
34
|
throw new Error(`File not found: ${path}`);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isCompiledBinary, logger, Snowflake } from "@gajae-code/utils";
|
|
2
|
+
import { registerResourceOwner } from "../../runtime/process-lifecycle";
|
|
2
3
|
import type { ToolSession } from "../../tools";
|
|
3
4
|
import { ToolAbortError, ToolError } from "../../tools/tool-errors";
|
|
4
5
|
import { callSessionTool, type JsStatusEvent } from "./tool-bridge";
|
|
@@ -43,22 +44,66 @@ interface PendingRun {
|
|
|
43
44
|
settled: boolean;
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
interface ReadyDeferred {
|
|
48
|
+
promise: Promise<JsSession>;
|
|
49
|
+
resolve(session: JsSession): void;
|
|
50
|
+
reject(error: Error): void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface QueueDeferred {
|
|
54
|
+
promise: Promise<void>;
|
|
55
|
+
resolve(): void;
|
|
56
|
+
reject(error: Error): void;
|
|
57
|
+
}
|
|
58
|
+
|
|
46
59
|
interface JsSession {
|
|
47
60
|
sessionKey: string;
|
|
48
|
-
worker
|
|
49
|
-
state: "alive" | "dead";
|
|
61
|
+
worker?: WorkerHandle;
|
|
62
|
+
state: "starting" | "alive" | "dead";
|
|
63
|
+
ownerId?: string;
|
|
50
64
|
pending: Map<string, PendingRun>;
|
|
51
65
|
queue: Promise<void>;
|
|
66
|
+
queuedWaiters: Set<(error: Error) => void>;
|
|
67
|
+
queueTail: QueueDeferred;
|
|
68
|
+
controllers: Set<AbortController>;
|
|
69
|
+
runSignal?: AbortSignal;
|
|
70
|
+
ready: ReadyDeferred;
|
|
71
|
+
unsubscribe?: () => void;
|
|
72
|
+
unregistered?: () => void;
|
|
52
73
|
}
|
|
53
74
|
|
|
54
75
|
const sessions = new Map<string, JsSession>();
|
|
76
|
+
const sessionWaiters = new Map<string, Set<(error: Error) => void>>();
|
|
77
|
+
let vmResourceCleanupRegistered = false;
|
|
78
|
+
|
|
79
|
+
function ensureVmResourceCleanup(): void {
|
|
80
|
+
if (vmResourceCleanupRegistered) return;
|
|
81
|
+
vmResourceCleanupRegistered = true;
|
|
82
|
+
registerResourceOwner("js-vm-contexts", async () => {
|
|
83
|
+
try {
|
|
84
|
+
await disposeAllVmContexts();
|
|
85
|
+
} finally {
|
|
86
|
+
vmResourceCleanupRegistered = false;
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
|
55
90
|
const READY_TIMEOUT_MS_DEFAULT = 5_000;
|
|
56
91
|
|
|
92
|
+
function getSessionWaiters(sessionKey: string): Set<(error: Error) => void> {
|
|
93
|
+
let waiters = sessionWaiters.get(sessionKey);
|
|
94
|
+
if (!waiters) {
|
|
95
|
+
waiters = new Set();
|
|
96
|
+
sessionWaiters.set(sessionKey, waiters);
|
|
97
|
+
}
|
|
98
|
+
return waiters;
|
|
99
|
+
}
|
|
100
|
+
|
|
57
101
|
export async function executeInVmContext(options: {
|
|
58
102
|
sessionKey: string;
|
|
59
103
|
sessionId: string;
|
|
60
104
|
cwd: string;
|
|
61
105
|
session: ToolSession;
|
|
106
|
+
ownerId?: string;
|
|
62
107
|
reset?: boolean;
|
|
63
108
|
code: string;
|
|
64
109
|
filename: string;
|
|
@@ -68,40 +113,84 @@ export async function executeInVmContext(options: {
|
|
|
68
113
|
if (options.reset) {
|
|
69
114
|
await resetVmContext(options.sessionKey);
|
|
70
115
|
}
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
)
|
|
76
|
-
|
|
116
|
+
const waiters = getSessionWaiters(options.sessionKey);
|
|
117
|
+
const { promise: contextResetPromise, reject: rejectContextReset } = Promise.withResolvers<never>();
|
|
118
|
+
contextResetPromise.catch(() => undefined);
|
|
119
|
+
waiters.add(rejectContextReset);
|
|
120
|
+
const runPromise = (async (): Promise<{ value: unknown }> => {
|
|
121
|
+
const session = await acquireSession(
|
|
122
|
+
options.sessionKey,
|
|
123
|
+
{ cwd: options.cwd, sessionId: options.sessionId },
|
|
124
|
+
options.ownerId,
|
|
125
|
+
options.timeoutMs,
|
|
126
|
+
);
|
|
127
|
+
return await runQueued(session, () => runOnce(session, options));
|
|
128
|
+
})();
|
|
129
|
+
try {
|
|
130
|
+
return await Promise.race([runPromise, contextResetPromise]);
|
|
131
|
+
} finally {
|
|
132
|
+
waiters.delete(rejectContextReset);
|
|
133
|
+
}
|
|
77
134
|
}
|
|
78
135
|
|
|
79
136
|
export async function resetVmContext(sessionKey: string): Promise<void> {
|
|
80
137
|
const session = sessions.get(sessionKey);
|
|
81
138
|
if (!session) return;
|
|
82
139
|
sessions.delete(sessionKey);
|
|
140
|
+
const waiters = sessionWaiters.get(sessionKey);
|
|
141
|
+
if (waiters) for (const reject of [...waiters]) reject(new ToolError("JS context reset"));
|
|
83
142
|
await killSession(session, new ToolError("JS context reset"));
|
|
84
143
|
}
|
|
85
144
|
|
|
145
|
+
export async function disposeVmContextsByOwner(ownerId: string): Promise<void> {
|
|
146
|
+
const owned = [...sessions.entries()].filter(
|
|
147
|
+
([sessionKey, session]) =>
|
|
148
|
+
session.ownerId === ownerId || sessionKey === ownerId || sessionKey === `js:${ownerId}`,
|
|
149
|
+
);
|
|
150
|
+
for (const [sessionKey, session] of owned) {
|
|
151
|
+
if (sessions.get(sessionKey) === session) sessions.delete(sessionKey);
|
|
152
|
+
}
|
|
153
|
+
await Promise.all(owned.map(([, session]) => killSession(session, new ToolError("JS context disposed"))));
|
|
154
|
+
}
|
|
155
|
+
|
|
86
156
|
export async function disposeAllVmContexts(): Promise<void> {
|
|
87
157
|
const all = [...sessions.values()];
|
|
88
158
|
sessions.clear();
|
|
89
159
|
await Promise.all(all.map(session => killSession(session, new ToolError("JS context disposed"))));
|
|
90
160
|
}
|
|
91
161
|
|
|
162
|
+
export function liveVmContextCount(): number {
|
|
163
|
+
return [...sessions.values()].filter(session => session.state !== "dead").length;
|
|
164
|
+
}
|
|
165
|
+
|
|
92
166
|
async function runQueued<T>(session: JsSession, work: () => Promise<T>): Promise<T> {
|
|
167
|
+
if (session.state !== "alive") throw new ToolError("JS worker is not alive");
|
|
93
168
|
const previous = session.queue;
|
|
94
|
-
const { promise, resolve } = Promise.withResolvers<void>();
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
169
|
+
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
|
170
|
+
const queueController = new AbortController();
|
|
171
|
+
const queueItem: QueueDeferred = { promise, resolve, reject };
|
|
172
|
+
const rejectWaiter = (error: Error): void => queueItem.reject(error);
|
|
173
|
+
session.queuedWaiters.add(rejectWaiter);
|
|
174
|
+
session.controllers.add(queueController);
|
|
175
|
+
session.queueTail = queueItem;
|
|
176
|
+
session.queue = (async () => {
|
|
177
|
+
await previous.catch(() => undefined);
|
|
178
|
+
await queueItem.promise;
|
|
179
|
+
})().catch(() => undefined);
|
|
101
180
|
try {
|
|
102
|
-
|
|
181
|
+
await Promise.race([previous.catch(() => undefined), queueItem.promise, abortPromise(queueController.signal)]);
|
|
182
|
+
if (session.runSignal?.aborted) throw reasonToError(session.runSignal.reason, "JS worker is not alive");
|
|
183
|
+
if (session.state !== "alive") throw new ToolError("JS worker is not alive");
|
|
184
|
+
session.queuedWaiters.delete(rejectWaiter);
|
|
185
|
+
return await Promise.race([
|
|
186
|
+
work(),
|
|
187
|
+
queueItem.promise.then(() => new Promise<never>(() => {})),
|
|
188
|
+
abortPromise(queueController.signal),
|
|
189
|
+
]);
|
|
103
190
|
} finally {
|
|
104
|
-
|
|
191
|
+
session.queuedWaiters.delete(rejectWaiter);
|
|
192
|
+
session.controllers.delete(queueController);
|
|
193
|
+
queueItem.resolve();
|
|
105
194
|
}
|
|
106
195
|
}
|
|
107
196
|
|
|
@@ -118,6 +207,7 @@ async function runOnce(
|
|
|
118
207
|
): Promise<{ value: unknown }> {
|
|
119
208
|
const runId = `r-${Snowflake.next()}`;
|
|
120
209
|
const { promise, resolve, reject } = Promise.withResolvers<{ value: unknown }>();
|
|
210
|
+
const sessionSignal = session.runSignal;
|
|
121
211
|
const pending: PendingRun = {
|
|
122
212
|
runId,
|
|
123
213
|
runState: options.runState,
|
|
@@ -145,13 +235,19 @@ async function runOnce(
|
|
|
145
235
|
}
|
|
146
236
|
|
|
147
237
|
try {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
238
|
+
if (sessionSignal?.aborted) throw reasonToError(sessionSignal.reason, "JS worker is not alive");
|
|
239
|
+
if (
|
|
240
|
+
!safeSend(session, {
|
|
241
|
+
type: "run",
|
|
242
|
+
runId,
|
|
243
|
+
code: options.code,
|
|
244
|
+
filename: options.filename,
|
|
245
|
+
snapshot: { cwd: options.cwd, sessionId: options.sessionId },
|
|
246
|
+
})
|
|
247
|
+
) {
|
|
248
|
+
settleRunWithError(session, pending, new ToolError("JS worker send failed"));
|
|
249
|
+
return await promise;
|
|
250
|
+
}
|
|
155
251
|
return await promise;
|
|
156
252
|
} finally {
|
|
157
253
|
options.runState.signal?.removeEventListener("abort", onAbort);
|
|
@@ -159,46 +255,76 @@ async function runOnce(
|
|
|
159
255
|
}
|
|
160
256
|
}
|
|
161
257
|
|
|
162
|
-
async function acquireSession(
|
|
258
|
+
async function acquireSession(
|
|
259
|
+
sessionKey: string,
|
|
260
|
+
snapshot: SessionSnapshot,
|
|
261
|
+
ownerId: string | undefined,
|
|
262
|
+
timeoutMs?: number,
|
|
263
|
+
): Promise<JsSession> {
|
|
264
|
+
ensureVmResourceCleanup();
|
|
163
265
|
const existing = sessions.get(sessionKey);
|
|
164
|
-
if (existing && existing.state
|
|
266
|
+
if (existing && existing.state !== "dead") return await existing.ready.promise;
|
|
165
267
|
|
|
166
|
-
const
|
|
268
|
+
const { promise: ready, resolve: resolveSession, reject: rejectSession } = Promise.withResolvers<JsSession>();
|
|
269
|
+
ready.catch(() => undefined);
|
|
167
270
|
const session: JsSession = {
|
|
168
271
|
sessionKey,
|
|
169
|
-
|
|
170
|
-
|
|
272
|
+
state: "starting",
|
|
273
|
+
ownerId,
|
|
171
274
|
pending: new Map(),
|
|
172
275
|
queue: Promise.resolve(),
|
|
276
|
+
queuedWaiters: new Set(),
|
|
277
|
+
queueTail: settledQueueDeferred(),
|
|
278
|
+
controllers: new Set(),
|
|
279
|
+
ready: { promise: ready, resolve: resolveSession, reject: rejectSession },
|
|
173
280
|
};
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
if (!resolved && msg.type === "ready") {
|
|
178
|
-
resolved = true;
|
|
179
|
-
resolveReady();
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
182
|
-
if (!resolved && msg.type === "init-failed") {
|
|
183
|
-
resolved = true;
|
|
184
|
-
rejectReady(errorFromPayload(msg.error));
|
|
185
|
-
return;
|
|
186
|
-
}
|
|
187
|
-
handleSessionMessage(session, msg);
|
|
188
|
-
});
|
|
281
|
+
sessions.set(sessionKey, session);
|
|
282
|
+
|
|
283
|
+
let worker: WorkerHandle | undefined;
|
|
189
284
|
try {
|
|
190
|
-
|
|
191
|
-
|
|
285
|
+
worker = await spawnJsWorker();
|
|
286
|
+
const current = sessions.get(sessionKey);
|
|
287
|
+
if (current !== session) {
|
|
288
|
+
await worker.terminate().catch(() => undefined);
|
|
289
|
+
return (
|
|
290
|
+
(await current?.ready.promise) ?? Promise.reject(new ToolError("JS context replaced during initialization"))
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
session.worker = worker;
|
|
294
|
+
const { promise: readyPromise, resolve: resolveReady, reject: rejectReady } = Promise.withResolvers<void>();
|
|
295
|
+
let resolved = false;
|
|
296
|
+
session.unsubscribe = worker.onMessage(msg => {
|
|
297
|
+
if (!resolved && msg.type === "ready") {
|
|
298
|
+
resolved = true;
|
|
299
|
+
resolveReady();
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (!resolved && msg.type === "init-failed") {
|
|
303
|
+
resolved = true;
|
|
304
|
+
rejectReady(errorFromPayload(msg.error));
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
handleSessionMessage(session, msg);
|
|
308
|
+
});
|
|
192
309
|
const readyTimeoutMs = Math.max(READY_TIMEOUT_MS_DEFAULT, timeoutMs ?? 0);
|
|
193
310
|
await raceWithTimeout(readyPromise, readyTimeoutMs, "Timed out initializing JS eval worker");
|
|
311
|
+
if (sessions.get(sessionKey) !== session) {
|
|
312
|
+
await killSession(session, new ToolError("JS context replaced during initialization"));
|
|
313
|
+
return (
|
|
314
|
+
(await sessions.get(sessionKey)?.ready.promise) ??
|
|
315
|
+
Promise.reject(new ToolError("JS context replaced during initialization"))
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
worker.send({ type: "init", snapshot });
|
|
319
|
+
session.state = "alive";
|
|
320
|
+
session.ready.resolve(session);
|
|
321
|
+
return session;
|
|
194
322
|
} catch (error) {
|
|
195
|
-
|
|
196
|
-
await
|
|
323
|
+
if (sessions.get(sessionKey) === session) sessions.delete(sessionKey);
|
|
324
|
+
await killSession(session, error instanceof Error ? error : new Error(String(error)));
|
|
325
|
+
session.ready.reject(error instanceof Error ? error : new Error(String(error)));
|
|
197
326
|
throw error;
|
|
198
327
|
}
|
|
199
|
-
worker.send({ type: "init", snapshot });
|
|
200
|
-
sessions.set(sessionKey, session);
|
|
201
|
-
return session;
|
|
202
328
|
}
|
|
203
329
|
|
|
204
330
|
function handleSessionMessage(session: JsSession, msg: WorkerOutbound): void {
|
|
@@ -276,22 +402,54 @@ async function killSessionFor(session: JsSession, error: Error): Promise<void> {
|
|
|
276
402
|
async function killSession(session: JsSession, error: Error): Promise<void> {
|
|
277
403
|
if (session.state === "dead") return;
|
|
278
404
|
session.state = "dead";
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
405
|
+
const unsubscribe = session.unsubscribe;
|
|
406
|
+
session.unsubscribe = undefined;
|
|
407
|
+
unsubscribe?.();
|
|
408
|
+
session.ready.reject(error);
|
|
409
|
+
session.queueTail.reject(error);
|
|
410
|
+
for (const controller of [...session.controllers]) controller.abort(error);
|
|
411
|
+
session.controllers.clear();
|
|
412
|
+
session.runSignal = AbortSignal.abort(error);
|
|
413
|
+
for (const reject of [...session.queuedWaiters]) reject(error);
|
|
414
|
+
session.queuedWaiters.clear();
|
|
415
|
+
for (const pending of [...session.pending.values()]) settleRunWithError(session, pending, error);
|
|
285
416
|
session.pending.clear();
|
|
286
|
-
|
|
417
|
+
void session.worker?.terminate().catch(() => undefined);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function settleRunWithError(session: JsSession, pending: PendingRun, error: Error): void {
|
|
421
|
+
if (pending.settled) return;
|
|
422
|
+
pending.settled = true;
|
|
423
|
+
for (const ctrl of pending.toolCalls.values()) ctrl.abort(error);
|
|
424
|
+
pending.toolCalls.clear();
|
|
425
|
+
session.pending.delete(pending.runId);
|
|
426
|
+
pending.reject(error);
|
|
287
427
|
}
|
|
288
428
|
|
|
289
|
-
function
|
|
290
|
-
|
|
429
|
+
function settledQueueDeferred(): QueueDeferred {
|
|
430
|
+
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
|
431
|
+
resolve();
|
|
432
|
+
return { promise, resolve, reject };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function abortPromise(signal: AbortSignal): Promise<never> {
|
|
436
|
+
if (signal.aborted) return Promise.reject(reasonToError(signal.reason, "JS worker is not alive"));
|
|
437
|
+
const { promise, reject } = Promise.withResolvers<never>();
|
|
438
|
+
const onAbort = (): void => reject(reasonToError(signal.reason, "JS worker is not alive"));
|
|
439
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
440
|
+
promise.finally(() => signal.removeEventListener("abort", onAbort)).catch(() => undefined);
|
|
441
|
+
return promise;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function safeSend(session: JsSession, msg: WorkerInbound): boolean {
|
|
445
|
+
if (session.state !== "alive") return false;
|
|
291
446
|
try {
|
|
292
|
-
session.worker
|
|
447
|
+
session.worker?.send(msg);
|
|
448
|
+
return true;
|
|
293
449
|
} catch (err) {
|
|
294
450
|
logger.debug("js worker send failed", { error: err instanceof Error ? err.message : String(err) });
|
|
451
|
+
void killSessionFor(session, err instanceof Error ? err : new Error(String(err)));
|
|
452
|
+
return false;
|
|
295
453
|
}
|
|
296
454
|
}
|
|
297
455
|
|
|
@@ -352,10 +510,15 @@ async function spawnJsWorker(): Promise<WorkerHandle> {
|
|
|
352
510
|
: new Worker(new URL("./worker-entry.ts", import.meta.url).href, { type: "module" });
|
|
353
511
|
return wrapBunWorker(worker);
|
|
354
512
|
} catch (err) {
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
513
|
+
if (process.env.GAJAE_CODE_JS_EVAL_INLINE_WORKER === "1") {
|
|
514
|
+
logger.warn("Bun Worker spawn failed; using test-only inline JS eval worker", {
|
|
515
|
+
error: err instanceof Error ? err.message : String(err),
|
|
516
|
+
});
|
|
517
|
+
return spawnInlineWorker();
|
|
518
|
+
}
|
|
519
|
+
throw new ToolError(
|
|
520
|
+
`JS eval worker is unavailable and inline fallback is disabled because it cannot interrupt synchronous user code: ${err instanceof Error ? err.message : String(err)}`,
|
|
521
|
+
);
|
|
359
522
|
}
|
|
360
523
|
}
|
|
361
524
|
|
package/src/eval/js/executor.ts
CHANGED
|
@@ -10,6 +10,7 @@ export interface JsExecutorOptions {
|
|
|
10
10
|
onChunk?: (chunk: string) => Promise<void> | void;
|
|
11
11
|
signal?: AbortSignal;
|
|
12
12
|
sessionId: string;
|
|
13
|
+
ownerId?: string;
|
|
13
14
|
reset?: boolean;
|
|
14
15
|
sessionFile?: string;
|
|
15
16
|
artifactPath?: string;
|
|
@@ -68,6 +69,7 @@ export async function executeJs(code: string, options: JsExecutorOptions): Promi
|
|
|
68
69
|
await executeInVmContext({
|
|
69
70
|
sessionKey: options.sessionId,
|
|
70
71
|
sessionId: options.sessionId,
|
|
72
|
+
ownerId: options.ownerId,
|
|
71
73
|
cwd: options.cwd ?? options.session.cwd,
|
|
72
74
|
session: options.session,
|
|
73
75
|
reset: options.reset,
|
package/src/eval/js/index.ts
CHANGED