@gajae-code/coding-agent 0.4.5 → 0.5.1
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 +62 -0
- package/dist/types/async/job-manager.d.ts +26 -0
- package/dist/types/cli/args.d.ts +1 -0
- package/dist/types/cli/list-models.d.ts +6 -0
- package/dist/types/commands/gc.d.ts +26 -0
- package/dist/types/commands/harness.d.ts +3 -0
- package/dist/types/config/file-lock-gc.d.ts +5 -0
- package/dist/types/config/file-lock.d.ts +7 -0
- package/dist/types/config/model-profile-activation.d.ts +11 -2
- package/dist/types/config/model-profiles.d.ts +7 -0
- package/dist/types/config/model-registry.d.ts +3 -0
- package/dist/types/config/model-resolver.d.ts +2 -0
- package/dist/types/config/models-config-schema.d.ts +30 -0
- package/dist/types/config/settings-schema.d.ts +4 -3
- package/dist/types/coordinator/contract.d.ts +1 -1
- package/dist/types/defaults/gjc/extensions/grok-build/index.d.ts +1 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/index.d.ts +1 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.d.ts +25 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/payload/sanitize.d.ts +27 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/billing.d.ts +8 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.d.ts +5 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.d.ts +10 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/usage.d.ts +2 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.d.ts +2 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/shared/errors.d.ts +38 -0
- package/dist/types/defaults/gjc-grok-cli.d.ts +5 -0
- package/dist/types/extensibility/extensions/index.d.ts +1 -0
- package/dist/types/extensibility/extensions/prefix-command-bridge.d.ts +35 -0
- package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +103 -0
- package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +2 -0
- package/dist/types/gjc-runtime/deep-interview-state.d.ts +112 -0
- package/dist/types/gjc-runtime/gc-render.d.ts +6 -0
- package/dist/types/gjc-runtime/gc-runtime.d.ts +134 -0
- package/dist/types/gjc-runtime/ledger-event-renderer.d.ts +68 -0
- package/dist/types/gjc-runtime/team-gc.d.ts +7 -0
- package/dist/types/gjc-runtime/team-runtime.d.ts +5 -1
- package/dist/types/gjc-runtime/tmux-common.d.ts +14 -0
- package/dist/types/gjc-runtime/tmux-gc.d.ts +7 -0
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +13 -0
- package/dist/types/harness-control-plane/gc-adapter.d.ts +3 -0
- package/dist/types/harness-control-plane/owner.d.ts +8 -1
- package/dist/types/harness-control-plane/receipt-spool.d.ts +19 -0
- package/dist/types/harness-control-plane/state-machine.d.ts +6 -1
- package/dist/types/harness-control-plane/storage.d.ts +20 -0
- package/dist/types/harness-control-plane/types.d.ts +4 -0
- package/dist/types/hindsight/mental-models.d.ts +5 -5
- package/dist/types/modes/components/hook-selector.d.ts +7 -1
- package/dist/types/modes/components/model-selector.d.ts +1 -12
- package/dist/types/modes/controllers/command-controller.d.ts +1 -0
- package/dist/types/modes/rpc/rpc-client.d.ts +2 -2
- package/dist/types/modes/rpc/rpc-mode.d.ts +16 -1
- package/dist/types/modes/rpc/rpc-types.d.ts +4 -1
- package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +13 -0
- package/dist/types/modes/shared/agent-wire/session-registry.d.ts +25 -0
- package/dist/types/modes/shared/agent-wire/unattended-action-policy.d.ts +2 -0
- package/dist/types/sdk.d.ts +5 -0
- package/dist/types/session/agent-session.d.ts +3 -1
- package/dist/types/session/blob-store.d.ts +59 -4
- package/dist/types/session/session-manager.d.ts +24 -6
- package/dist/types/session/streaming-output.d.ts +3 -2
- package/dist/types/session/tool-choice-queue.d.ts +6 -0
- package/dist/types/skill-state/workflow-hud.d.ts +14 -0
- package/dist/types/task/receipt.d.ts +1 -0
- package/dist/types/task/types.d.ts +7 -0
- package/dist/types/thinking-metadata.d.ts +16 -0
- package/dist/types/thinking.d.ts +3 -12
- package/dist/types/tools/ask.d.ts +15 -1
- package/dist/types/tools/index.d.ts +2 -0
- package/dist/types/tools/resolve.d.ts +0 -10
- package/dist/types/tools/subagent.d.ts +6 -0
- package/dist/types/utils/tool-choice.d.ts +14 -1
- package/package.json +7 -7
- package/src/async/job-manager.ts +52 -0
- package/src/cli/args.ts +3 -0
- package/src/cli/auth-broker-cli.ts +1 -0
- package/src/cli/list-models.ts +13 -1
- package/src/cli.ts +9 -4
- package/src/commands/gc.ts +22 -0
- package/src/commands/harness.ts +43 -5
- package/src/commands/launch.ts +2 -2
- package/src/commands/session.ts +3 -1
- package/src/config/file-lock-gc.ts +181 -0
- package/src/config/file-lock.ts +14 -0
- package/src/config/model-profile-activation.ts +15 -3
- package/src/config/model-profiles.ts +264 -56
- package/src/config/model-resolver.ts +9 -6
- package/src/config/models-config-schema.ts +1 -0
- package/src/config/settings-schema.ts +6 -3
- package/src/coordinator/contract.ts +1 -0
- package/src/coordinator-mcp/server.ts +513 -26
- package/src/cursor.ts +16 -2
- package/src/defaults/gjc/agent.models.grok-cli.yml +36 -0
- package/src/defaults/gjc/extensions/grok-build/index.ts +1 -0
- package/src/defaults/gjc/extensions/grok-build/package.json +7 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/biome.json +39 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/package.json +8 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/index.ts +1 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +155 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/payload/sanitize.ts +361 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/billing.ts +57 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +99 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.ts +50 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/usage.ts +56 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.ts +36 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/errors.ts +44 -0
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +131 -113
- package/src/defaults/gjc/skills/deep-interview/lateral-review-panel.md +49 -0
- package/src/defaults/gjc/skills/team/SKILL.md +3 -2
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +8 -2
- package/src/defaults/gjc-defaults.ts +7 -0
- package/src/defaults/gjc-grok-cli.ts +22 -0
- package/src/export/html/index.ts +13 -9
- package/src/extensibility/extensions/index.ts +1 -0
- package/src/extensibility/extensions/prefix-command-bridge.ts +128 -0
- package/src/gjc-runtime/deep-interview-recorder.ts +417 -0
- package/src/gjc-runtime/deep-interview-runtime.ts +18 -26
- package/src/gjc-runtime/deep-interview-state.ts +324 -0
- package/src/gjc-runtime/gc-render.ts +70 -0
- package/src/gjc-runtime/gc-runtime.ts +403 -0
- package/src/gjc-runtime/ledger-event-renderer.ts +164 -0
- package/src/gjc-runtime/ralplan-runtime.ts +58 -7
- package/src/gjc-runtime/state-renderer.ts +12 -3
- package/src/gjc-runtime/state-runtime.ts +46 -29
- package/src/gjc-runtime/team-gc.ts +49 -0
- package/src/gjc-runtime/team-runtime.ts +211 -8
- package/src/gjc-runtime/tmux-common.ts +29 -0
- package/src/gjc-runtime/tmux-gc.ts +176 -0
- package/src/gjc-runtime/tmux-sessions.ts +68 -12
- package/src/gjc-runtime/ultragoal-runtime.ts +517 -41
- package/src/gjc-runtime/workflow-manifest.generated.json +27 -1
- package/src/gjc-runtime/workflow-manifest.ts +16 -1
- package/src/harness-control-plane/gc-adapter.ts +184 -0
- package/src/harness-control-plane/owner.ts +89 -27
- package/src/harness-control-plane/receipt-spool.ts +128 -0
- package/src/harness-control-plane/state-machine.ts +27 -6
- package/src/harness-control-plane/storage.ts +93 -0
- package/src/harness-control-plane/types.ts +4 -0
- package/src/hindsight/mental-models.ts +17 -16
- package/src/internal-urls/docs-index.generated.ts +14 -8
- package/src/main.ts +7 -2
- package/src/modes/components/assistant-message.ts +26 -14
- package/src/modes/components/diff.ts +97 -0
- package/src/modes/components/hook-selector.ts +19 -0
- package/src/modes/components/model-selector.ts +370 -181
- package/src/modes/components/status-line/segments.ts +1 -1
- package/src/modes/components/tool-execution.ts +30 -13
- package/src/modes/controllers/command-controller.ts +25 -6
- package/src/modes/controllers/extension-ui-controller.ts +3 -0
- package/src/modes/controllers/selector-controller.ts +34 -42
- package/src/modes/rpc/rpc-client.ts +3 -2
- package/src/modes/rpc/rpc-mode.ts +187 -39
- package/src/modes/rpc/rpc-types.ts +5 -2
- package/src/modes/shared/agent-wire/command-dispatch.ts +279 -257
- package/src/modes/shared/agent-wire/command-validation.ts +11 -0
- package/src/modes/shared/agent-wire/deep-interview-gate.ts +30 -1
- package/src/modes/shared/agent-wire/session-registry.ts +109 -0
- package/src/modes/shared/agent-wire/unattended-action-policy.ts +24 -0
- package/src/modes/shared/agent-wire/unattended-run-controller.ts +23 -3
- package/src/modes/shared/agent-wire/unattended-session.ts +16 -1
- package/src/sdk.ts +46 -5
- package/src/secrets/obfuscator.ts +102 -27
- package/src/session/agent-session.ts +179 -25
- package/src/session/blob-store.ts +148 -6
- package/src/session/session-manager.ts +311 -60
- package/src/session/streaming-output.ts +185 -122
- package/src/session/tool-choice-queue.ts +23 -0
- package/src/setup/hermes/templates/operator-instructions.v1.md +7 -1
- package/src/skill-state/workflow-hud.ts +106 -10
- package/src/slash-commands/builtin-registry.ts +3 -2
- package/src/task/executor.ts +78 -6
- package/src/task/receipt.ts +5 -0
- package/src/task/render.ts +21 -1
- package/src/task/types.ts +8 -0
- package/src/thinking-metadata.ts +51 -0
- package/src/thinking.ts +26 -46
- package/src/tools/ask.ts +56 -1
- package/src/tools/bash.ts +1 -1
- package/src/tools/index.ts +2 -0
- package/src/tools/job.ts +3 -2
- package/src/tools/monitor.ts +36 -1
- package/src/tools/resolve.ts +93 -18
- package/src/tools/subagent-render.ts +9 -0
- package/src/tools/subagent.ts +26 -2
- package/src/utils/edit-mode.ts +1 -1
- package/src/utils/tool-choice.ts +45 -16
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@gajae-code/coding-agent",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.5.1",
|
|
5
5
|
"description": "Gajae Code CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://gaebal-gajae.dev",
|
|
7
7
|
"author": "Yeachan-Heo",
|
|
@@ -51,12 +51,12 @@
|
|
|
51
51
|
"@agentclientprotocol/sdk": "0.21.0",
|
|
52
52
|
"@babel/parser": "^7.29.3",
|
|
53
53
|
"@mozilla/readability": "^0.6.0",
|
|
54
|
-
"@gajae-code/stats": "0.
|
|
55
|
-
"@gajae-code/agent-core": "0.
|
|
56
|
-
"@gajae-code/ai": "0.
|
|
57
|
-
"@gajae-code/natives": "0.
|
|
58
|
-
"@gajae-code/tui": "0.
|
|
59
|
-
"@gajae-code/utils": "0.
|
|
54
|
+
"@gajae-code/stats": "0.5.1",
|
|
55
|
+
"@gajae-code/agent-core": "0.5.1",
|
|
56
|
+
"@gajae-code/ai": "0.5.1",
|
|
57
|
+
"@gajae-code/natives": "0.5.1",
|
|
58
|
+
"@gajae-code/tui": "0.5.1",
|
|
59
|
+
"@gajae-code/utils": "0.5.1",
|
|
60
60
|
"@puppeteer/browsers": "^2.13.0",
|
|
61
61
|
"@types/turndown": "5.0.6",
|
|
62
62
|
"@xterm/headless": "^6.0.0",
|
package/src/async/job-manager.ts
CHANGED
|
@@ -13,6 +13,14 @@ export interface AsyncJob {
|
|
|
13
13
|
type: "bash" | "task";
|
|
14
14
|
status: "running" | "completed" | "failed" | "cancelled" | "paused";
|
|
15
15
|
startTime: number;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Wall-clock ms when the job left the `running` state (completed, failed,
|
|
19
|
+
* cancelled, or paused). Undefined while running. Frozen on the first
|
|
20
|
+
* terminal/pause transition so elapsed-time renderers stop counting once a
|
|
21
|
+
* job is no longer active instead of growing forever against `Date.now()`.
|
|
22
|
+
*/
|
|
23
|
+
endTime?: number;
|
|
16
24
|
label: string;
|
|
17
25
|
abortController: AbortController;
|
|
18
26
|
promise: Promise<void>;
|
|
@@ -28,6 +36,16 @@ export interface AsyncJob {
|
|
|
28
36
|
ownerId?: string;
|
|
29
37
|
}
|
|
30
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Elapsed wall-clock ms for a job, frozen once it stops running. While the job
|
|
41
|
+
* is active (`endTime` undefined) this counts against `now`; after it stops it
|
|
42
|
+
* returns the fixed `endTime - startTime` span so status renderers do not keep
|
|
43
|
+
* incrementing a completed job's timer.
|
|
44
|
+
*/
|
|
45
|
+
export function jobElapsedMs(job: Pick<AsyncJob, "startTime" | "endTime">, now: number = Date.now()): number {
|
|
46
|
+
return Math.max(0, (job.endTime ?? now) - job.startTime);
|
|
47
|
+
}
|
|
48
|
+
|
|
31
49
|
export interface AsyncJobMetadata {
|
|
32
50
|
subagent?: {
|
|
33
51
|
id: string;
|
|
@@ -80,6 +98,12 @@ export interface SubagentRecord {
|
|
|
80
98
|
/** False for ephemeral sessions (no persistent artifacts dir). */
|
|
81
99
|
resumable: boolean;
|
|
82
100
|
queued?: { ownerId?: string; seq: number; message?: string; createdAt: number };
|
|
101
|
+
/** Resolved model the subagent was asked to use, e.g. "openai-codex/gpt-5.5". */
|
|
102
|
+
requestedModel?: string;
|
|
103
|
+
/** Model actually used after auth fallback (#985); equals requestedModel when no fallback. */
|
|
104
|
+
effectiveModel?: string;
|
|
105
|
+
/** True when the requested model lacked credentials and the subagent fell back to the parent model. */
|
|
106
|
+
modelFellBack?: boolean;
|
|
83
107
|
}
|
|
84
108
|
|
|
85
109
|
/** Lightweight, manager-owned resume payload. The async layer treats `data` as opaque. */
|
|
@@ -368,6 +392,7 @@ export class AsyncJobManager {
|
|
|
368
392
|
// delivery and no eviction scheduling: a paused subagent stays
|
|
369
393
|
// listed and resumable from its sessionFile.
|
|
370
394
|
job.status = "paused";
|
|
395
|
+
this.#freezeEndTime(job);
|
|
371
396
|
if (outcome.note) job.resultText = outcome.note;
|
|
372
397
|
this.#markRecordPaused(id);
|
|
373
398
|
this.#drainResumeQueue();
|
|
@@ -375,6 +400,7 @@ export class AsyncJobManager {
|
|
|
375
400
|
}
|
|
376
401
|
|
|
377
402
|
job.status = "completed";
|
|
403
|
+
this.#freezeEndTime(job);
|
|
378
404
|
job.resultText = outcome.text;
|
|
379
405
|
this.#enqueueDelivery(id, outcome.text);
|
|
380
406
|
this.#runLifecycle(id, "terminal");
|
|
@@ -393,6 +419,7 @@ export class AsyncJobManager {
|
|
|
393
419
|
this.#runLifecycle(id, "terminal");
|
|
394
420
|
const errorText = error instanceof Error ? error.message : String(error);
|
|
395
421
|
job.status = "failed";
|
|
422
|
+
this.#freezeEndTime(job);
|
|
396
423
|
job.errorText = errorText;
|
|
397
424
|
this.#enqueueDelivery(id, errorText);
|
|
398
425
|
this.#scheduleEviction(id);
|
|
@@ -428,10 +455,22 @@ export class AsyncJobManager {
|
|
|
428
455
|
if (job.status !== "running") return false;
|
|
429
456
|
this.#runLifecycle(id, "cancel");
|
|
430
457
|
job.status = "cancelled";
|
|
458
|
+
this.#freezeEndTime(job);
|
|
431
459
|
job.abortController.abort();
|
|
432
460
|
return true;
|
|
433
461
|
}
|
|
434
462
|
|
|
463
|
+
/**
|
|
464
|
+
* Freeze the wall-clock instant a job stopped running. Idempotent: the
|
|
465
|
+
* first stop (completed/failed/cancelled/paused) wins so elapsed-time
|
|
466
|
+
* renderers report a stable duration instead of counting against
|
|
467
|
+
* `Date.now()` forever. A resumed subagent registers a brand-new job with
|
|
468
|
+
* its own `startTime`, so a paused job's frozen `endTime` is never reused.
|
|
469
|
+
*/
|
|
470
|
+
#freezeEndTime(job: AsyncJob): void {
|
|
471
|
+
job.endTime ??= Date.now();
|
|
472
|
+
}
|
|
473
|
+
|
|
435
474
|
#runLifecycle(jobId: string, phase: "cancel" | "terminal" | "evict"): void {
|
|
436
475
|
const fired = this.#lifecyclePhases.get(jobId) ?? new Set<"cancel" | "terminal" | "evict">();
|
|
437
476
|
if (fired.has(phase)) return;
|
|
@@ -503,6 +542,18 @@ export class AsyncJobManager {
|
|
|
503
542
|
this.#subagentRecords.set(record.subagentId, record);
|
|
504
543
|
}
|
|
505
544
|
|
|
545
|
+
/** Patch model metadata onto an existing subagent record (best-effort; no-op if unknown). */
|
|
546
|
+
updateSubagentModel(
|
|
547
|
+
subagentId: string,
|
|
548
|
+
model: { requestedModel?: string; effectiveModel?: string; modelFellBack?: boolean },
|
|
549
|
+
): void {
|
|
550
|
+
const record = this.#subagentRecords.get(subagentId);
|
|
551
|
+
if (!record) return;
|
|
552
|
+
record.requestedModel = model.requestedModel;
|
|
553
|
+
record.effectiveModel = model.effectiveModel;
|
|
554
|
+
record.modelFellBack = model.modelFellBack;
|
|
555
|
+
}
|
|
556
|
+
|
|
506
557
|
getSubagentRecord(subagentId: string, filter?: AsyncJobFilter): SubagentRecord | undefined {
|
|
507
558
|
const rec = this.#subagentRecords.get(subagentId.trim());
|
|
508
559
|
if (!rec) return undefined;
|
|
@@ -978,6 +1029,7 @@ export class AsyncJobManager {
|
|
|
978
1029
|
for (const job of this.getRunningJobs(filter)) {
|
|
979
1030
|
this.#runLifecycle(job.id, "cancel");
|
|
980
1031
|
job.status = "cancelled";
|
|
1032
|
+
this.#freezeEndTime(job);
|
|
981
1033
|
job.abortController.abort();
|
|
982
1034
|
this.#scheduleEviction(job.id);
|
|
983
1035
|
}
|
package/src/cli/args.ts
CHANGED
|
@@ -30,6 +30,7 @@ export interface Args {
|
|
|
30
30
|
mode?: Mode;
|
|
31
31
|
noSession?: boolean;
|
|
32
32
|
sessionDir?: string;
|
|
33
|
+
rpcListen?: string;
|
|
33
34
|
providerSessionId?: string;
|
|
34
35
|
fork?: string;
|
|
35
36
|
models?: string[];
|
|
@@ -145,6 +146,8 @@ export function parseArgs(args: string[]): Args {
|
|
|
145
146
|
result.noSession = true;
|
|
146
147
|
} else if (arg === "--session-dir" && i + 1 < args.length) {
|
|
147
148
|
result.sessionDir = args[++i];
|
|
149
|
+
} else if (arg === "--listen" && i + 1 < args.length) {
|
|
150
|
+
result.rpcListen = args[++i];
|
|
148
151
|
} else if (arg === "--models" && i + 1 < args.length) {
|
|
149
152
|
result.models = args[++i].split(",").map(s => s.trim());
|
|
150
153
|
} else if (arg === "--no-tools") {
|
package/src/cli/list-models.ts
CHANGED
|
@@ -5,7 +5,12 @@ import { type Api, getSupportedEfforts, type Model } from "@gajae-code/ai";
|
|
|
5
5
|
import { fuzzyFilter } from "@gajae-code/tui";
|
|
6
6
|
import { formatNumber } from "@gajae-code/utils";
|
|
7
7
|
import type { ModelRegistry } from "../config/model-registry";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
discoverAndLoadExtensions,
|
|
10
|
+
type ExtensionFactory,
|
|
11
|
+
loadExtensionFromFactory,
|
|
12
|
+
loadExtensions,
|
|
13
|
+
} from "../extensibility/extensions";
|
|
9
14
|
import { EventBus } from "../utils/event-bus";
|
|
10
15
|
|
|
11
16
|
interface ProviderRow {
|
|
@@ -137,6 +142,8 @@ export interface RunListModelsOptions {
|
|
|
137
142
|
cwd: string;
|
|
138
143
|
/** CLI-supplied extension paths (e.g. from `-e <path>`). */
|
|
139
144
|
additionalExtensionPaths?: string[];
|
|
145
|
+
/** In-process extension factories to load without filesystem discovery. */
|
|
146
|
+
extensionFactories?: Array<{ factory: ExtensionFactory; name: string }>;
|
|
140
147
|
/** Extension paths configured under `extensions:` in user settings. */
|
|
141
148
|
settingsExtensions?: string[];
|
|
142
149
|
/** Disabled extension ids from settings (`disabledExtensions`). */
|
|
@@ -159,6 +166,7 @@ export async function runListModelsCommand(options: RunListModelsOptions): Promi
|
|
|
159
166
|
modelRegistry,
|
|
160
167
|
cwd,
|
|
161
168
|
additionalExtensionPaths = [],
|
|
169
|
+
extensionFactories = [],
|
|
162
170
|
settingsExtensions = [],
|
|
163
171
|
disabledExtensionIds = [],
|
|
164
172
|
disableExtensionDiscovery = false,
|
|
@@ -174,6 +182,10 @@ export async function runListModelsCommand(options: RunListModelsOptions): Promi
|
|
|
174
182
|
eventBus,
|
|
175
183
|
disabledExtensionIds,
|
|
176
184
|
);
|
|
185
|
+
for (const { factory, name } of extensionFactories) {
|
|
186
|
+
const extension = await loadExtensionFromFactory(factory, cwd, eventBus, extensionsResult.runtime, name);
|
|
187
|
+
extensionsResult.extensions.push(extension);
|
|
188
|
+
}
|
|
177
189
|
|
|
178
190
|
for (const { path: extPath, error } of extensionsResult.errors) {
|
|
179
191
|
process.stderr.write(`Failed to load extension: ${extPath}: ${error}\n`);
|
package/src/cli.ts
CHANGED
|
@@ -5,11 +5,15 @@
|
|
|
5
5
|
* lightweight CLI runner from pi-utils.
|
|
6
6
|
*/
|
|
7
7
|
import { Args, type CliConfig, Command, type CommandEntry, Flags, run } from "@gajae-code/utils/cli";
|
|
8
|
-
import { APP_NAME, MIN_BUN_VERSION, VERSION } from "@gajae-code/utils/dirs";
|
|
8
|
+
import { APP_NAME, formatBunRuntimeError, MIN_BUN_VERSION, VERSION } from "@gajae-code/utils/dirs";
|
|
9
9
|
|
|
10
10
|
if (Bun.semver.order(Bun.version, MIN_BUN_VERSION) < 0) {
|
|
11
11
|
process.stderr.write(
|
|
12
|
-
|
|
12
|
+
formatBunRuntimeError({
|
|
13
|
+
currentVersion: Bun.version,
|
|
14
|
+
minVersion: MIN_BUN_VERSION,
|
|
15
|
+
execPath: process.execPath,
|
|
16
|
+
}),
|
|
13
17
|
);
|
|
14
18
|
process.exit(1);
|
|
15
19
|
}
|
|
@@ -28,6 +32,7 @@ const commands: CommandEntry[] = [
|
|
|
28
32
|
{ name: "coordinator", load: () => import("./commands/coordinator").then(m => m.default) },
|
|
29
33
|
{ name: "team", load: () => import("./commands/team").then(m => m.default) },
|
|
30
34
|
{ name: "ultragoal", load: () => import("./commands/ultragoal").then(m => m.default) },
|
|
35
|
+
{ name: "gc", load: () => import("./commands/gc").then(m => m.default) },
|
|
31
36
|
{ name: "ralplan", load: () => import("./commands/ralplan").then(m => m.default) },
|
|
32
37
|
{ name: "config", load: () => import("./commands/config").then(m => m.default) },
|
|
33
38
|
{ name: "mcp-serve", load: () => import("./commands/mcp-serve").then(m => m.default) },
|
|
@@ -130,8 +135,8 @@ class RootHelpCommand extends Command {
|
|
|
130
135
|
`# Launch in a sibling git worktree\n ${APP_NAME} --worktree`,
|
|
131
136
|
`# Use different model (fuzzy matching)\n ${APP_NAME} --model opus "Help me refactor this code"`,
|
|
132
137
|
`# Limit model cycling to specific models\n ${APP_NAME} --models claude-sonnet,claude-haiku,gpt-4o`,
|
|
133
|
-
`# Activate a model profile for this session\n ${APP_NAME} --mpreset codex-
|
|
134
|
-
`# Persist a model profile as the default\n ${APP_NAME} --mpreset
|
|
138
|
+
`# Activate a model profile for this session\n ${APP_NAME} --mpreset codex-medium`,
|
|
139
|
+
`# Persist a model profile as the default\n ${APP_NAME} --mpreset opencodego --default`,
|
|
135
140
|
`# Export a session file to HTML\n ${APP_NAME} --export ~/.gjc/agent/sessions/--path--/session.jsonl`,
|
|
136
141
|
];
|
|
137
142
|
static strict = false;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Command, Flags } from "@gajae-code/utils/cli";
|
|
2
|
+
import { runGjcGcCommand } from "../gjc-runtime/gc-runtime";
|
|
3
|
+
|
|
4
|
+
export default class Gc extends Command {
|
|
5
|
+
static description = "Garbage-collect stale GJC session/PID records (dry-run by default)";
|
|
6
|
+
static strict = false;
|
|
7
|
+
static flags = {
|
|
8
|
+
json: Flags.boolean({ char: "j", description: "Emit machine-readable JSON", default: false }),
|
|
9
|
+
prune: Flags.boolean({ description: "Remove stale records (default: report only)", default: false }),
|
|
10
|
+
force: Flags.boolean({ description: "Alias for --prune (eligible records only)", default: false }),
|
|
11
|
+
"dry-run": Flags.boolean({ description: "Force report-only mode", default: false }),
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
static examples = ["gjc gc", "gjc gc --json", "gjc gc --prune", "gjc gc --prune --json"];
|
|
15
|
+
|
|
16
|
+
async run(): Promise<void> {
|
|
17
|
+
const result = await runGjcGcCommand(this.argv, process.cwd(), process.env);
|
|
18
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
19
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
20
|
+
process.exitCode = result.status;
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/commands/harness.ts
CHANGED
|
@@ -11,16 +11,18 @@
|
|
|
11
11
|
import { execFileSync } from "node:child_process";
|
|
12
12
|
import { randomBytes } from "node:crypto";
|
|
13
13
|
import { existsSync, readFileSync } from "node:fs";
|
|
14
|
+
import * as path from "node:path";
|
|
14
15
|
import { Args, Command, Flags } from "@gajae-code/utils/cli";
|
|
15
16
|
import { resolveGjcTmuxCommand, sanitizeTmuxToken } from "../gjc-runtime/tmux-common";
|
|
16
17
|
import { classifyRecovery } from "../harness-control-plane/classifier";
|
|
17
18
|
import { callEndpoint, EndpointUnreachableError } from "../harness-control-plane/control-endpoint";
|
|
18
|
-
import { type ResolvedOwner, RuntimeOwner, resolveOwner } from "../harness-control-plane/owner";
|
|
19
|
+
import { type ResolvedOwner, RuntimeOwner, resolveOwner, resolveOwnerLive } from "../harness-control-plane/owner";
|
|
19
20
|
import { preserveDirtyWorktree } from "../harness-control-plane/preserve";
|
|
21
|
+
import { RECEIPT_SPOOL_DIR_ENV } from "../harness-control-plane/receipt-spool";
|
|
20
22
|
import { buildReceipt, requiresVanishBeforeAction, type VanishEvidence } from "../harness-control-plane/receipts";
|
|
21
23
|
import { GajaeCodeRpc } from "../harness-control-plane/rpc-adapter";
|
|
22
24
|
import { classifyLeaseStatus, readLease } from "../harness-control-plane/session-lease";
|
|
23
|
-
import { buildResponse, buildStateView } from "../harness-control-plane/state-machine";
|
|
25
|
+
import { buildResponse, buildStateView, submitUnavailableReason } from "../harness-control-plane/state-machine";
|
|
24
26
|
import {
|
|
25
27
|
canonicalWorkspacePath,
|
|
26
28
|
generateSessionId,
|
|
@@ -513,6 +515,9 @@ export default class Harness extends Command {
|
|
|
513
515
|
cursor: Flags.string({ description: "Event cursor for events --follow (exclusive)", default: "0" }),
|
|
514
516
|
follow: Flags.boolean({ description: "Tail the owner-written event log", default: false }),
|
|
515
517
|
json: Flags.boolean({ char: "j", description: "Emit machine-readable JSON", default: true }),
|
|
518
|
+
"receipt-spool-dir": Flags.string({
|
|
519
|
+
description: "Append persisted ReceiptEnvelope records to spool.jsonl under this directory",
|
|
520
|
+
}),
|
|
516
521
|
};
|
|
517
522
|
|
|
518
523
|
static examples = [
|
|
@@ -527,6 +532,11 @@ export default class Harness extends Command {
|
|
|
527
532
|
const verb = String(args.verb);
|
|
528
533
|
let root = resolveHarnessRoot();
|
|
529
534
|
try {
|
|
535
|
+
const receiptSpoolDir = flags["receipt-spool-dir"];
|
|
536
|
+
if (receiptSpoolDir !== undefined) {
|
|
537
|
+
if (!receiptSpoolDir.trim()) throw new Error("receipt_spool_dir_empty");
|
|
538
|
+
process.env[RECEIPT_SPOOL_DIR_ENV] = path.resolve(receiptSpoolDir.trim());
|
|
539
|
+
}
|
|
530
540
|
const input = parseInput(flags.input);
|
|
531
541
|
const promptFile = flags["prompt-file"];
|
|
532
542
|
if (promptFile !== undefined) {
|
|
@@ -676,6 +686,9 @@ export default class Harness extends Command {
|
|
|
676
686
|
}
|
|
677
687
|
const sessionName = deterministicHarnessTmuxSessionName(sessionId);
|
|
678
688
|
const envAssignments = [`GJC_HARNESS_STATE_ROOT=${shellQuote(root)}`];
|
|
689
|
+
if (process.env[RECEIPT_SPOOL_DIR_ENV]) {
|
|
690
|
+
envAssignments.push(`${RECEIPT_SPOOL_DIR_ENV}=${shellQuote(process.env[RECEIPT_SPOOL_DIR_ENV])}`);
|
|
691
|
+
}
|
|
679
692
|
if (process.env.GJC_HARNESS_RPC_COMMAND) {
|
|
680
693
|
envAssignments.push(`GJC_HARNESS_RPC_COMMAND=${shellQuote(process.env.GJC_HARNESS_RPC_COMMAND)}`);
|
|
681
694
|
}
|
|
@@ -715,6 +728,9 @@ export default class Harness extends Command {
|
|
|
715
728
|
env: {
|
|
716
729
|
...process.env,
|
|
717
730
|
GJC_HARNESS_STATE_ROOT: root,
|
|
731
|
+
...(process.env[RECEIPT_SPOOL_DIR_ENV]
|
|
732
|
+
? { [RECEIPT_SPOOL_DIR_ENV]: process.env[RECEIPT_SPOOL_DIR_ENV] }
|
|
733
|
+
: {}),
|
|
718
734
|
...(process.env.GJC_HARNESS_TEST_NODE_MODULES
|
|
719
735
|
? { GJC_HARNESS_TEST_NODE_MODULES: process.env.GJC_HARNESS_TEST_NODE_MODULES }
|
|
720
736
|
: {}),
|
|
@@ -878,7 +894,9 @@ export default class Harness extends Command {
|
|
|
878
894
|
): Promise<boolean> {
|
|
879
895
|
const owner = await resolveOwner(root, sessionId);
|
|
880
896
|
if (!owner.live || !owner.socketPath) return false;
|
|
897
|
+
const priorSpoolDir = input[RECEIPT_SPOOL_DIR_ENV];
|
|
881
898
|
try {
|
|
899
|
+
if (process.env[RECEIPT_SPOOL_DIR_ENV]) input[RECEIPT_SPOOL_DIR_ENV] = process.env[RECEIPT_SPOOL_DIR_ENV];
|
|
882
900
|
const res = (await callEndpoint(owner.socketPath, { verb, input })) as { ok?: boolean };
|
|
883
901
|
writeJson(res);
|
|
884
902
|
if (res?.ok === false) process.exitCode = 1;
|
|
@@ -886,6 +904,9 @@ export default class Harness extends Command {
|
|
|
886
904
|
} catch (error) {
|
|
887
905
|
if (error instanceof EndpointUnreachableError) return false;
|
|
888
906
|
throw error;
|
|
907
|
+
} finally {
|
|
908
|
+
if (priorSpoolDir === undefined) delete input[RECEIPT_SPOOL_DIR_ENV];
|
|
909
|
+
else input[RECEIPT_SPOOL_DIR_ENV] = priorSpoolDir;
|
|
889
910
|
}
|
|
890
911
|
}
|
|
891
912
|
|
|
@@ -927,10 +948,14 @@ export default class Harness extends Command {
|
|
|
927
948
|
let observation = input.observation as Partial<Observation> | undefined;
|
|
928
949
|
let stateView: SessionState | null = null;
|
|
929
950
|
const sessionId = flagSession ?? (typeof input.sessionId === "string" ? input.sessionId : undefined);
|
|
951
|
+
// Session-backed classify derives owner liveness from the same lease/socket probe observe
|
|
952
|
+
// uses for routing, so a live (e.g. manual) owner is never misread as vanished/restart-clean.
|
|
953
|
+
let ownerLive = false;
|
|
930
954
|
if (sessionId) {
|
|
931
955
|
stateView = await loadState(root, sessionId);
|
|
956
|
+
ownerLive = await resolveOwnerLive(root, sessionId);
|
|
932
957
|
if (!observation) {
|
|
933
|
-
const built = await buildObservation(root, stateView,
|
|
958
|
+
const built = await buildObservation(root, stateView, ownerLive);
|
|
934
959
|
observation = built.observation;
|
|
935
960
|
stateView = await markVanishedOwnerBlocked(
|
|
936
961
|
root,
|
|
@@ -954,7 +979,7 @@ export default class Harness extends Command {
|
|
|
954
979
|
const decision = classifyRecovery({ observation: full, retryBudget: budget });
|
|
955
980
|
if (stateView) {
|
|
956
981
|
writeJson(
|
|
957
|
-
buildResponse(stateView,
|
|
982
|
+
buildResponse(stateView, ownerLive, {
|
|
958
983
|
decision,
|
|
959
984
|
observation: { ...full, lifecycle: stateView.lifecycle },
|
|
960
985
|
}),
|
|
@@ -978,8 +1003,21 @@ export default class Harness extends Command {
|
|
|
978
1003
|
|
|
979
1004
|
async #submit(root: string, input: Record<string, unknown>, flagSession: string | undefined): Promise<void> {
|
|
980
1005
|
const sessionId = requireSessionId(input, flagSession);
|
|
981
|
-
if (await this.#tryOwnerRoute(root, sessionId, "submit", { ...input, sessionId })) return;
|
|
982
1006
|
let state = await loadState(root, sessionId);
|
|
1007
|
+
const noOwnerGate = submitUnavailableReason(state.lifecycle, false);
|
|
1008
|
+
if (!noOwnerGate || noOwnerGate === "owner-not-live") {
|
|
1009
|
+
if (await this.#tryOwnerRoute(root, sessionId, "submit", { ...input, sessionId })) return;
|
|
1010
|
+
state = await loadState(root, sessionId);
|
|
1011
|
+
}
|
|
1012
|
+
const blockedByOwnerLiveness = state.blockers.some(
|
|
1013
|
+
blocker => isOwnerLivenessBlocker(blocker) || blocker === OWNER_STARTUP_BLOCKER,
|
|
1014
|
+
);
|
|
1015
|
+
const lifecycleGate = submitUnavailableReason(state.lifecycle, false);
|
|
1016
|
+
if (lifecycleGate && lifecycleGate !== "owner-not-live" && !blockedByOwnerLiveness) {
|
|
1017
|
+
writeJson(buildResponse(state, false, { accepted: false, submitted: false, reason: lifecycleGate }, false));
|
|
1018
|
+
process.exitCode = 1;
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
983
1021
|
// No live owner: submission is blocked (never echoed-as-accepted). Surface owner exit
|
|
984
1022
|
// evidence + explicit recovery guidance so the caller is not left with a bare gate.
|
|
985
1023
|
const ownerExit = await buildOwnerExitEvidence(root, state);
|
package/src/commands/launch.ts
CHANGED
|
@@ -142,8 +142,8 @@ export default class Index extends Command {
|
|
|
142
142
|
`# Launch in a sibling git worktree\n ${APP_NAME} --worktree`,
|
|
143
143
|
`# Use different model (fuzzy matching)\n ${APP_NAME} --model opus "Help me refactor this code"`,
|
|
144
144
|
`# Limit model cycling to specific models\n ${APP_NAME} --models claude-sonnet,claude-haiku,gpt-4o`,
|
|
145
|
-
`# Activate a model profile for this session\n ${APP_NAME} --mpreset codex-
|
|
146
|
-
`# Persist a model profile as the default\n ${APP_NAME} --mpreset
|
|
145
|
+
`# Activate a model profile for this session\n ${APP_NAME} --mpreset codex-medium`,
|
|
146
|
+
`# Persist a model profile as the default\n ${APP_NAME} --mpreset opencodego --default`,
|
|
147
147
|
`# Export a session file to HTML\n ${APP_NAME} --export ~/.gjc/agent/sessions/--path--/session.jsonl`,
|
|
148
148
|
];
|
|
149
149
|
|
package/src/commands/session.ts
CHANGED
|
@@ -18,7 +18,9 @@ function writeText(lines: string[]): void {
|
|
|
18
18
|
function writeJsonFailure(error: unknown): void {
|
|
19
19
|
const message = error instanceof Error ? error.message : String(error);
|
|
20
20
|
const [reason = "session_error"] = message.split(":");
|
|
21
|
-
|
|
21
|
+
const hintIndex = message.indexOf(" — ");
|
|
22
|
+
const detail = hintIndex >= 0 ? message.slice(hintIndex + " — ".length).trim() : "";
|
|
23
|
+
writeJson(detail ? { ok: false, reason, detail } : { ok: false, reason });
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
interface SessionJsonDto {
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GC adapter for config file-locks (`<file>.lock` dirs holding `{pid, timestamp}`).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as fs from "node:fs/promises";
|
|
6
|
+
import * as path from "node:path";
|
|
7
|
+
import { getAgentDir, getConfigRootDir, isEnoent } from "@gajae-code/utils";
|
|
8
|
+
import type {
|
|
9
|
+
GcCollectResult,
|
|
10
|
+
GcContext,
|
|
11
|
+
GcError,
|
|
12
|
+
GcPruneOutcome,
|
|
13
|
+
GcRecord,
|
|
14
|
+
GcStoreAdapter,
|
|
15
|
+
} from "../gjc-runtime/gc-runtime";
|
|
16
|
+
import { gcPidStatusLabel } from "../gjc-runtime/gc-runtime";
|
|
17
|
+
import { resolveReceiptSpoolDir } from "../harness-control-plane/receipt-spool";
|
|
18
|
+
import { readFileLockInfoForGc, removeFileLockDirForGc } from "./file-lock";
|
|
19
|
+
|
|
20
|
+
const MAX_WALK_DEPTH = 6;
|
|
21
|
+
const MAX_WALK_ENTRIES = 20_000;
|
|
22
|
+
|
|
23
|
+
// High-cardinality, lock-free subtrees we never descend into. `.lock` dirs are
|
|
24
|
+
// created next to config files, never inside these.
|
|
25
|
+
const PRUNED_DIR_NAMES = new Set(["sessions", "node_modules", ".git", "blobs", "artifacts", "receipts", "events"]);
|
|
26
|
+
|
|
27
|
+
interface WalkState {
|
|
28
|
+
entries: number;
|
|
29
|
+
truncated: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Global, env-aware GJC lock roots. Per the approved scope this covers the
|
|
33
|
+
// user config root, the agent dir (honors GJC_CODING_AGENT_DIR), and the
|
|
34
|
+
// configured receipt-spool dir — NOT the invocation cwd's project `.gjc`.
|
|
35
|
+
function knownFileLockRoots(ctx: GcContext): string[] {
|
|
36
|
+
const roots = [getConfigRootDir(), getAgentDir()];
|
|
37
|
+
const spoolDir = resolveReceiptSpoolDir(ctx.env);
|
|
38
|
+
if (spoolDir) roots.push(spoolDir);
|
|
39
|
+
return Array.from(new Set(roots.map(root => path.resolve(root))));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function errorMessage(error: unknown): string {
|
|
43
|
+
return error instanceof Error ? error.message : String(error);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function keptMalformedRecord(lockDir: string): GcRecord {
|
|
47
|
+
return {
|
|
48
|
+
store: "file_locks",
|
|
49
|
+
id: lockDir,
|
|
50
|
+
path: lockDir,
|
|
51
|
+
pid_status: "none",
|
|
52
|
+
status: "malformed",
|
|
53
|
+
stale: false,
|
|
54
|
+
removable: false,
|
|
55
|
+
action: "none",
|
|
56
|
+
reason: "missing_or_malformed_file_lock_info",
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function collectLockRecord(lockDir: string, ctx: GcContext): Promise<GcRecord> {
|
|
61
|
+
const info = await readFileLockInfoForGc(lockDir);
|
|
62
|
+
if (!info) return keptMalformedRecord(lockDir);
|
|
63
|
+
|
|
64
|
+
const probeResult = ctx.probe(info.pid);
|
|
65
|
+
const pidStatus = gcPidStatusLabel(probeResult);
|
|
66
|
+
const removable = probeResult.status === "dead";
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
store: "file_locks",
|
|
70
|
+
id: lockDir,
|
|
71
|
+
path: lockDir,
|
|
72
|
+
pid: info.pid,
|
|
73
|
+
pid_status: pidStatus,
|
|
74
|
+
status: pidStatus,
|
|
75
|
+
stale: removable,
|
|
76
|
+
removable,
|
|
77
|
+
action: "none",
|
|
78
|
+
reason: removable ? "file_lock_owner_pid_dead" : `file_lock_owner_pid_${pidStatus}`,
|
|
79
|
+
detail: `timestamp=${info.timestamp}`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function walkForLockDirs(
|
|
84
|
+
dir: string,
|
|
85
|
+
depth: number,
|
|
86
|
+
state: WalkState,
|
|
87
|
+
lockDirs: Set<string>,
|
|
88
|
+
errors: GcError[],
|
|
89
|
+
): Promise<void> {
|
|
90
|
+
if (state.entries >= MAX_WALK_ENTRIES) {
|
|
91
|
+
state.truncated = true;
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let stat: Awaited<ReturnType<typeof fs.lstat>>;
|
|
96
|
+
try {
|
|
97
|
+
stat = await fs.lstat(dir);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (isEnoent(error)) return;
|
|
100
|
+
errors.push({ store: "file_locks", scope: dir, message: errorMessage(error) });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
state.entries++;
|
|
105
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) return;
|
|
106
|
+
|
|
107
|
+
if (path.basename(dir).endsWith(".lock")) {
|
|
108
|
+
lockDirs.add(dir);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (depth >= MAX_WALK_DEPTH) return;
|
|
113
|
+
|
|
114
|
+
let entries: string[];
|
|
115
|
+
try {
|
|
116
|
+
entries = await fs.readdir(dir);
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (isEnoent(error)) return;
|
|
119
|
+
errors.push({ store: "file_locks", scope: dir, message: errorMessage(error) });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for (const entry of entries) {
|
|
124
|
+
if (state.entries >= MAX_WALK_ENTRIES) {
|
|
125
|
+
state.truncated = true;
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (PRUNED_DIR_NAMES.has(entry)) continue;
|
|
129
|
+
await walkForLockDirs(path.join(dir, entry), depth + 1, state, lockDirs, errors);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export const fileLocksGcAdapter: GcStoreAdapter = {
|
|
134
|
+
store: "file_locks",
|
|
135
|
+
async collect(ctx: GcContext): Promise<GcCollectResult> {
|
|
136
|
+
const records: GcRecord[] = [];
|
|
137
|
+
const errors: GcError[] = [];
|
|
138
|
+
const lockDirs = new Set<string>();
|
|
139
|
+
const state: WalkState = { entries: 0, truncated: false };
|
|
140
|
+
|
|
141
|
+
for (const root of knownFileLockRoots(ctx)) {
|
|
142
|
+
await walkForLockDirs(root, 0, state, lockDirs, errors);
|
|
143
|
+
if (state.truncated) break;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (state.truncated) {
|
|
147
|
+
errors.push({
|
|
148
|
+
store: "file_locks",
|
|
149
|
+
scope: "discovery",
|
|
150
|
+
message: `file lock discovery capped at ${MAX_WALK_ENTRIES} entries`,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
for (const lockDir of lockDirs) {
|
|
155
|
+
try {
|
|
156
|
+
records.push(await collectLockRecord(lockDir, ctx));
|
|
157
|
+
} catch (error) {
|
|
158
|
+
errors.push({ store: "file_locks", scope: lockDir, message: errorMessage(error) });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return { records, errors };
|
|
163
|
+
},
|
|
164
|
+
async prune(record: GcRecord, ctx: GcContext): Promise<GcPruneOutcome> {
|
|
165
|
+
const lockDir = record.path ?? record.id;
|
|
166
|
+
const info = await readFileLockInfoForGc(lockDir);
|
|
167
|
+
if (!info) return { removed: false, skipped: "lock_no_longer_dead_or_missing" };
|
|
168
|
+
|
|
169
|
+
const probeResult = ctx.probe(info.pid);
|
|
170
|
+
if (probeResult.status !== "dead") {
|
|
171
|
+
return { removed: false, skipped: "lock_no_longer_dead_or_missing" };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
await removeFileLockDirForGc(lockDir);
|
|
176
|
+
return { removed: true };
|
|
177
|
+
} catch (error) {
|
|
178
|
+
return { removed: false, error: errorMessage(error) };
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
};
|
package/src/config/file-lock.ts
CHANGED
|
@@ -36,6 +36,20 @@ async function readLockInfo(lockPath: string): Promise<LockInfo | null> {
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/** @internal */
|
|
40
|
+
export async function readFileLockInfoForGc(lockDir: string): Promise<{ pid: number; timestamp: number } | null> {
|
|
41
|
+
const info = await readLockInfo(lockDir);
|
|
42
|
+
if (!info) return null;
|
|
43
|
+
if (!Number.isFinite(info.pid) || info.pid <= 0) return null;
|
|
44
|
+
if (!Number.isFinite(info.timestamp)) return null;
|
|
45
|
+
return info;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** @internal */
|
|
49
|
+
export async function removeFileLockDirForGc(lockDir: string): Promise<void> {
|
|
50
|
+
await fs.rm(lockDir, { recursive: true, force: true });
|
|
51
|
+
}
|
|
52
|
+
|
|
39
53
|
function isProcessAlive(pid: number): boolean {
|
|
40
54
|
try {
|
|
41
55
|
process.kill(pid, 0);
|