@gajae-code/coding-agent 0.12.21 → 0.13.0
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 +67 -2
- package/dist/types/cli/args.d.ts +5 -2
- package/dist/types/cli/root-flags.d.ts +246 -0
- package/dist/types/cli.d.ts +11 -19
- package/dist/types/commands/daemon.d.ts +0 -30
- package/dist/types/commands/launch.d.ts +11 -19
- package/dist/types/commands/quick-lane.d.ts +32 -0
- package/dist/types/commands/sdk.d.ts +21 -15
- package/dist/types/config/file-lock-gc.d.ts +2 -0
- package/dist/types/config/model-equivalence.d.ts +20 -0
- package/dist/types/config/model-profile-activation.d.ts +62 -17
- package/dist/types/config/model-profiles.d.ts +10 -0
- package/dist/types/config/model-registry.d.ts +68 -1
- package/dist/types/config/model-resolver.d.ts +15 -4
- package/dist/types/config/models-config-schema.d.ts +7 -2
- package/dist/types/config/provider-selection-policy.d.ts +62 -0
- package/dist/types/config/settings-schema.d.ts +36 -78
- package/dist/types/config/settings.d.ts +2 -0
- package/dist/types/edit/renderer.d.ts +5 -2
- package/dist/types/edit/streaming.d.ts +13 -0
- package/dist/types/extensibility/custom-tools/types.d.ts +2 -0
- package/dist/types/extensibility/extensions/compact-handler.d.ts +2 -1
- package/dist/types/extensibility/extensions/types.d.ts +3 -0
- package/dist/types/gjc-runtime/gc-runtime.d.ts +19 -5
- package/dist/types/internal-urls/local-protocol.d.ts +1 -0
- package/dist/types/modes/components/model-selector.d.ts +0 -2
- package/dist/types/modes/components/provider-order-context.d.ts +45 -0
- package/dist/types/modes/components/provider-order-editor.d.ts +24 -0
- package/dist/types/modes/components/settings-defs.d.ts +5 -1
- package/dist/types/modes/components/settings-selector.d.ts +2 -0
- package/dist/types/quick-lane/classify.d.ts +40 -0
- package/dist/types/runtime/stt-settings-setup.d.ts +18 -0
- package/dist/types/sdk/broker/broker.d.ts +56 -2
- package/dist/types/sdk/broker/operation-receipt-ledger.d.ts +25 -0
- package/dist/types/sdk/broker/session-index.d.ts +83 -2
- package/dist/types/sdk/broker/state-version.d.ts +1 -1
- package/dist/types/sdk/bus/notification-service.d.ts +6 -0
- package/dist/types/sdk/bus/prompt-reconciliation.d.ts +8 -1
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +4 -1
- package/dist/types/sdk/bus/telegram-daemon.d.ts +26 -0
- package/dist/types/sdk/cli/rows.d.ts +98 -0
- package/dist/types/sdk/cli/session-cli.d.ts +35 -3
- package/dist/types/sdk/elevation/allowlist.d.ts +20 -0
- package/dist/types/sdk/elevation/audit.d.ts +93 -0
- package/dist/types/sdk/elevation/capability.d.ts +3 -0
- package/dist/types/sdk/elevation/digest.d.ts +72 -0
- package/dist/types/sdk/elevation/dispatch-receipt.d.ts +85 -0
- package/dist/types/sdk/elevation/grant-ledger.d.ts +163 -0
- package/dist/types/sdk/elevation/owner.d.ts +48 -0
- package/dist/types/sdk/guides/cache.d.ts +98 -0
- package/dist/types/sdk/guides/catalog.d.ts +156 -0
- package/dist/types/sdk/guides/cli.d.ts +39 -0
- package/dist/types/sdk/guides/manifest.d.ts +66 -0
- package/dist/types/sdk/guides/verify.d.ts +66 -0
- package/dist/types/sdk/host/control/dispatch.d.ts +8 -0
- package/dist/types/sdk/host/control/index.d.ts +1 -1
- package/dist/types/sdk/host/control/operations.d.ts +3 -2
- package/dist/types/sdk/host/query/cursor.d.ts +10 -0
- package/dist/types/sdk/host/query/handlers.d.ts +21 -0
- package/dist/types/sdk/host/session-runtime.d.ts +9 -0
- package/dist/types/sdk/mcp/server.d.ts +2 -2
- package/dist/types/sdk/prompt-status.d.ts +11 -0
- package/dist/types/sdk/session.d.ts +4 -0
- package/dist/types/sdk/transport/index.d.ts +1 -0
- package/dist/types/sdk/transport/relay.d.ts +1 -0
- package/dist/types/sdk/transport/serve-cli.d.ts +20 -1
- package/dist/types/session/agent-session.d.ts +32 -18
- package/dist/types/session/blob-store.d.ts +13 -7
- package/dist/types/session/fallback-chain-controller.d.ts +15 -0
- package/dist/types/session/internal/managed-session-storage.d.ts +25 -1
- package/dist/types/session/internal/session-memory-sidecar.d.ts +5 -5
- package/dist/types/session/session-manager.d.ts +139 -10
- package/dist/types/session/session-storage.d.ts +48 -0
- package/dist/types/setup/provider-onboarding.d.ts +7 -1
- package/dist/types/slash-commands/builtin-registry.d.ts +14 -0
- package/dist/types/stt/setup.d.ts +2 -0
- package/dist/types/task/executor.d.ts +10 -2
- package/dist/types/tools/browser/runtime-diagnostics.d.ts +65 -0
- package/dist/types/tools/browser/tab-protocol.d.ts +4 -0
- package/dist/types/tools/browser/tab-supervisor.d.ts +2 -0
- package/dist/types/tools/browser/tab-worker.d.ts +2 -0
- package/dist/types/tools/browser.d.ts +2 -0
- package/dist/types/tools/image-gen.d.ts +14 -18
- package/dist/types/tools/index.d.ts +2 -0
- package/dist/types/tools/resource-gc.d.ts +2 -0
- package/dist/types/utils/pasted-image-path.d.ts +6 -0
- package/package.json +9 -9
- package/scripts/generate-sdk-operation-inventory.ts +8 -0
- package/scripts/run-test-manifest.ts +61 -21
- package/scripts/verify-gjc-sdk-canonicalization.ts +9 -4
- package/src/cli/args.ts +62 -9
- package/src/cli/completion-cli.ts +1 -0
- package/src/cli/fast-help.ts +1 -0
- package/src/cli/root-flags.ts +100 -0
- package/src/cli/setup-cli.ts +6 -2
- package/src/cli.ts +42 -65
- package/src/commands/acp.ts +1 -1
- package/src/commands/daemon.ts +2 -31
- package/src/commands/gc.ts +1 -1
- package/src/commands/launch.ts +8 -129
- package/src/commands/quick-lane.ts +51 -0
- package/src/commands/sdk.ts +200 -26
- package/src/commands/setup.ts +4 -2
- package/src/config/file-lock-gc.ts +4 -1
- package/src/config/model-equivalence.ts +76 -7
- package/src/config/model-profile-activation.ts +1115 -265
- package/src/config/model-profiles.ts +134 -2
- package/src/config/model-registry.ts +352 -50
- package/src/config/model-resolver.ts +289 -38
- package/src/config/models-config-schema.ts +11 -4
- package/src/config/provider-selection-policy.ts +146 -0
- package/src/config/settings-schema.ts +50 -77
- package/src/config/settings.ts +40 -0
- package/src/coordinator-mcp/server.ts +32 -2
- package/src/edit/renderer.ts +175 -223
- package/src/edit/streaming.ts +108 -1
- package/src/export/html/index.ts +57 -7
- package/src/extensibility/custom-tools/types.ts +2 -0
- package/src/extensibility/extensions/compact-handler.ts +3 -2
- package/src/extensibility/extensions/runner.ts +6 -0
- package/src/extensibility/extensions/types.ts +3 -0
- package/src/gjc-runtime/gc-runtime.ts +379 -20
- package/src/gjc-runtime/launch-worktree.ts +5 -1
- package/src/gjc-runtime/team-runtime.ts +59 -2
- package/src/gjc-runtime/tmux-owner-isolation.ts +19 -0
- package/src/gjc-runtime/ultragoal-receipt-freshness.ts +16 -1
- package/src/internal-urls/docs-index.generated.ts +17 -13
- package/src/internal-urls/local-protocol.ts +1 -0
- package/src/main.ts +40 -3
- package/src/memories/index.ts +4 -2
- package/src/modes/acp/acp-agent.ts +235 -91
- package/src/modes/components/model-selector.ts +206 -42
- package/src/modes/components/provider-order-context.ts +197 -0
- package/src/modes/components/provider-order-editor.ts +441 -0
- package/src/modes/components/settings-defs.ts +21 -1
- package/src/modes/components/settings-selector.ts +45 -2
- package/src/modes/components/tool-execution.ts +47 -15
- package/src/modes/controllers/command-controller.ts +6 -3
- package/src/modes/controllers/extension-ui-controller.ts +2 -0
- package/src/modes/controllers/input-controller.ts +73 -2
- package/src/modes/controllers/selector-controller.ts +260 -153
- package/src/modes/prompt-suggestion-controller.ts +1 -1
- package/src/modes/runtime-init.ts +1 -0
- package/src/modes/utils/ui-helpers.ts +18 -0
- package/src/prompts/tools/browser.md +1 -0
- package/src/quick-lane/classify.ts +257 -0
- package/src/rlm/index.ts +1 -1
- package/src/runtime/stt-settings-setup.ts +57 -0
- package/src/sdk/acp/adapter.ts +2 -1
- package/src/sdk/broker/broker.ts +780 -12
- package/src/sdk/broker/lifecycle.ts +70 -29
- package/src/sdk/broker/operation-receipt-ledger.ts +144 -0
- package/src/sdk/broker/session-index.ts +463 -48
- package/src/sdk/broker/state-version.ts +13 -9
- package/src/sdk/broker/transport.ts +14 -1
- package/src/sdk/bus/index.ts +34 -13
- package/src/sdk/bus/kind-aware-reconciliation.ts +21 -2
- package/src/sdk/bus/notification-service.ts +47 -1
- package/src/sdk/bus/prompt-reconciliation.ts +22 -3
- package/src/sdk/bus/telegram-daemon-contract.ts +4 -1
- package/src/sdk/bus/telegram-daemon.ts +54 -3
- package/src/sdk/cli/rows.ts +247 -0
- package/src/sdk/cli/session-cli.ts +930 -79
- package/src/sdk/elevation/allowlist.ts +54 -0
- package/src/sdk/elevation/audit.ts +208 -0
- package/src/sdk/elevation/capability.ts +41 -0
- package/src/sdk/elevation/digest.ts +129 -0
- package/src/sdk/elevation/dispatch-receipt.ts +184 -0
- package/src/sdk/elevation/grant-ledger.ts +1142 -0
- package/src/sdk/elevation/owner.ts +109 -0
- package/src/sdk/guides/cache.ts +363 -0
- package/src/sdk/guides/catalog.ts +554 -0
- package/src/sdk/guides/cli.ts +235 -0
- package/src/sdk/guides/manifest.ts +152 -0
- package/src/sdk/guides/verify.ts +178 -0
- package/src/sdk/host/control/dispatch.ts +49 -1
- package/src/sdk/host/control/index.ts +1 -0
- package/src/sdk/host/control/operations.ts +8 -1
- package/src/sdk/host/query/cursor.ts +47 -3
- package/src/sdk/host/query/handlers.ts +155 -2
- package/src/sdk/host/sdk-surface-parity.test.ts +1 -0
- package/src/sdk/host/session-runtime.test.ts +391 -1
- package/src/sdk/host/session-runtime.ts +308 -73
- package/src/sdk/host/surface-policy.ts +3 -0
- package/src/sdk/host/websocket-transport.ts +7 -1
- package/src/sdk/mcp/server.ts +88 -15
- package/src/sdk/prompt-status.ts +8 -0
- package/src/sdk/protocol/operation-inventory.generated.json +62 -0
- package/src/sdk/protocol/operation-registry.ts +3 -2
- package/src/sdk/session.ts +87 -39
- package/src/sdk/transport/index.ts +1 -0
- package/src/sdk/transport/relay.ts +3 -0
- package/src/sdk/transport/serve-cli.ts +111 -24
- package/src/secrets/obfuscator.ts +92 -12
- package/src/session/agent-session.ts +322 -93
- package/src/session/blob-store.ts +60 -8
- package/src/session/fallback-chain-controller.ts +35 -0
- package/src/session/internal/managed-session-scope.ts +25 -3
- package/src/session/internal/managed-session-storage.ts +574 -73
- package/src/session/internal/session-memory-sidecar.ts +19 -29
- package/src/session/session-manager.ts +2166 -743
- package/src/session/session-storage.ts +715 -48
- package/src/setup/provider-onboarding.ts +19 -3
- package/src/setup/provider-presets.json +3 -1
- package/src/slash-commands/builtin-registry.ts +85 -3
- package/src/stt/downloader.ts +2 -3
- package/src/stt/setup.ts +29 -4
- package/src/task/executor.ts +22 -18
- package/src/task/index.ts +24 -1
- package/src/tools/archive-reader.ts +11 -2
- package/src/tools/browser/runtime-diagnostics.ts +249 -0
- package/src/tools/browser/tab-protocol.ts +4 -0
- package/src/tools/browser/tab-supervisor.ts +5 -0
- package/src/tools/browser/tab-worker.ts +36 -1
- package/src/tools/browser.ts +7 -0
- package/src/tools/image-gen.ts +110 -166
- package/src/tools/index.ts +2 -0
- package/src/tools/read.ts +1 -1
- package/src/tools/resource-gc.ts +6 -1
- package/src/tools/tool-catalog.generated.ts +5 -1
- package/src/utils/pasted-image-path.ts +93 -0
- package/src/web/search/index.ts +3 -3
|
@@ -528,15 +528,16 @@ export function gcHelpText(): string {
|
|
|
528
528
|
" --dry-run Force report-only mode (overrides --prune/--force)",
|
|
529
529
|
" -j, --json Emit machine-readable JSON",
|
|
530
530
|
" --repair-session-index Explicitly quarantine a corrupt session-index suffix and retain its valid prefix",
|
|
531
|
-
" --disk Also report on-disk retention (sessions, blobs, natives, backups)",
|
|
531
|
+
" --disk Also report on-disk retention (sessions, blobs, artifacts, natives, backups)",
|
|
532
532
|
"",
|
|
533
533
|
"Liveness-only: a record is removed only when its owning process is dead",
|
|
534
534
|
"(ESRCH). Live / permission-denied / unknown processes are always kept.",
|
|
535
535
|
"",
|
|
536
536
|
"Disk retention (--disk) is a separate, opt-in axis. Without --prune it only",
|
|
537
537
|
"reports reclaimable bytes per surface. Live, referenced, permission-denied or",
|
|
538
|
-
"otherwise ambiguous state is always KEPT and the report says why.
|
|
539
|
-
"
|
|
538
|
+
"otherwise ambiguous state is always KEPT and the report says why. Session tool",
|
|
539
|
+
"artifacts (`*.<tool>.log`, `.artifact-id-*`, evicted output) are reported per",
|
|
540
|
+
"family. Managed worktrees under ~/.gjc/wt are never touched.",
|
|
540
541
|
"",
|
|
541
542
|
].join("\n");
|
|
542
543
|
}
|
|
@@ -580,15 +581,21 @@ export async function defaultGcAdapters(): Promise<GcStoreAdapter[]> {
|
|
|
580
581
|
// needs evidence-based merge detection, which this axis does not have.
|
|
581
582
|
|
|
582
583
|
/** On-disk surfaces the retention axis can reclaim. */
|
|
583
|
-
export type GcDiskSurface = "sessions" | "blobs" | "natives" | "backups";
|
|
584
|
-
|
|
585
|
-
export const GC_DISK_SURFACES: readonly GcDiskSurface[] = [
|
|
584
|
+
export type GcDiskSurface = "sessions" | "blobs" | "artifacts" | "natives" | "backups";
|
|
585
|
+
|
|
586
|
+
export const GC_DISK_SURFACES: readonly GcDiskSurface[] = [
|
|
587
|
+
"sessions",
|
|
588
|
+
"blobs",
|
|
589
|
+
"artifacts",
|
|
590
|
+
"natives",
|
|
591
|
+
"backups",
|
|
592
|
+
] as const;
|
|
586
593
|
|
|
587
594
|
export type GcDiskAction = "keep" | "would_reclaim" | "reclaimed" | "reclaim_failed";
|
|
588
595
|
|
|
589
596
|
export interface GcDiskRecord {
|
|
590
597
|
surface: GcDiskSurface;
|
|
591
|
-
/** Session id, blob hash, natives version, or backup entry name. */
|
|
598
|
+
/** Session id, blob hash, `<session id>/<filename>` artifact, natives version, or backup entry name. */
|
|
592
599
|
id: string;
|
|
593
600
|
path: string;
|
|
594
601
|
bytes: number;
|
|
@@ -603,8 +610,21 @@ export interface GcDiskRecord {
|
|
|
603
610
|
}
|
|
604
611
|
|
|
605
612
|
/**
|
|
606
|
-
*
|
|
607
|
-
*
|
|
613
|
+
* Per-family rollup of a surface whose growth is many small files.
|
|
614
|
+
*
|
|
615
|
+
* The artifacts surface writes one record per file, which answers "what may go"
|
|
616
|
+
* but not "what filled the scope". A family is derived from the filename shape
|
|
617
|
+
* (`*.bash.log`, `.artifact-id-*`, …) rather than a fixed list, so a tool that
|
|
618
|
+
* starts writing a new kind of log is counted the day it ships.
|
|
619
|
+
*/
|
|
620
|
+
export interface GcDiskFamilyUsage {
|
|
621
|
+
family: string;
|
|
622
|
+
count: number;
|
|
623
|
+
bytes: number;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Why a surface withheld reclaim candidates because its evidence was incomplete.
|
|
608
628
|
*/
|
|
609
629
|
export interface GcDiskDeclined {
|
|
610
630
|
reason: string;
|
|
@@ -624,8 +644,10 @@ export interface GcDiskSurfaceReport {
|
|
|
624
644
|
kept: number;
|
|
625
645
|
kept_bytes: number;
|
|
626
646
|
failed: number;
|
|
627
|
-
/** Set when the surface
|
|
647
|
+
/** Set when the surface withheld reclaim candidates because its evidence was incomplete. */
|
|
628
648
|
declined?: GcDiskDeclined;
|
|
649
|
+
/** Per-family counts and bytes. Only surfaces whose records are individual files set this. */
|
|
650
|
+
families?: GcDiskFamilyUsage[];
|
|
629
651
|
records: GcDiskRecord[];
|
|
630
652
|
}
|
|
631
653
|
|
|
@@ -673,6 +695,17 @@ const GC_DISK_BLOB_GRACE_MS = GC_DISK_DAY_MS;
|
|
|
673
695
|
* sweep on, so the sweep withholds instead.
|
|
674
696
|
*/
|
|
675
697
|
const GC_DISK_MARK_REMARK_ROUNDS = 2;
|
|
698
|
+
/**
|
|
699
|
+
* How long the store must be observed completely quiet before the sweep may
|
|
700
|
+
* remove anything. The mark rounds and their drift checks can each complete
|
|
701
|
+
* inside an inter-append gap of a live session, so a single clean observation
|
|
702
|
+
* is not evidence of quiescence: the sweep brackets its decision with a probe
|
|
703
|
+
* that spans this window, which is far larger than any realistic gap between
|
|
704
|
+
* two transcript appends. A store that still moves inside the window withholds
|
|
705
|
+
* the sweep with `sessions_changed_during_mark` instead of reclaiming on
|
|
706
|
+
* evidence it could not prove stable.
|
|
707
|
+
*/
|
|
708
|
+
const GC_DISK_MARK_QUIESCENCE_MS = 50;
|
|
676
709
|
|
|
677
710
|
/** Bound every recursive size walk so a pathological tree cannot stall `gjc gc`. */
|
|
678
711
|
const GC_DISK_MAX_WALK_ENTRIES = 200_000;
|
|
@@ -1165,8 +1198,12 @@ interface GcDiskMarkedTranscript {
|
|
|
1165
1198
|
|
|
1166
1199
|
/**
|
|
1167
1200
|
* Read one transcript's blob references and bind the result to the bytes that
|
|
1168
|
-
* produced it.
|
|
1169
|
-
*
|
|
1201
|
+
* produced it. The read is fenced by a stat before it and a stat after it: an
|
|
1202
|
+
* append landing between the two (or after the read but before the fence) can
|
|
1203
|
+
* introduce a reference the pass never saw, so the mark is only recorded when
|
|
1204
|
+
* both stats agree on the bytes the read had to see. Anything that moved while
|
|
1205
|
+
* the read was in flight degrades `evidence` instead of silently shrinking the
|
|
1206
|
+
* reference set.
|
|
1170
1207
|
*/
|
|
1171
1208
|
async function markGcDiskTranscript(
|
|
1172
1209
|
transcriptPath: string,
|
|
@@ -1175,18 +1212,35 @@ async function markGcDiskTranscript(
|
|
|
1175
1212
|
evidence: GcDiskEvidence,
|
|
1176
1213
|
errors: GcDiskError[],
|
|
1177
1214
|
): Promise<void> {
|
|
1215
|
+
let before: Stats;
|
|
1216
|
+
try {
|
|
1217
|
+
before = await fsp.lstat(transcriptPath);
|
|
1218
|
+
} catch (error) {
|
|
1219
|
+
markGcDiskEvidenceIncomplete(evidence, "transcript_unstattable_before_mark");
|
|
1220
|
+
errors.push({ surface: "blobs", scope: transcriptPath, message: gcDiskErrorText(error) });
|
|
1221
|
+
return;
|
|
1222
|
+
}
|
|
1178
1223
|
if (!(await markGcDiskBlobReferences(transcriptPath, referenced))) {
|
|
1179
1224
|
markGcDiskEvidenceIncomplete(evidence, "transcript_unreadable_during_mark");
|
|
1180
1225
|
errors.push({ surface: "blobs", scope: transcriptPath, message: "transcript_unreadable_during_mark" });
|
|
1181
1226
|
return;
|
|
1182
1227
|
}
|
|
1228
|
+
let after: Stats;
|
|
1183
1229
|
try {
|
|
1184
|
-
|
|
1185
|
-
marked.set(transcriptPath, { size: stat.size, mtimeMs: stat.mtimeMs });
|
|
1230
|
+
after = await fsp.lstat(transcriptPath);
|
|
1186
1231
|
} catch (error) {
|
|
1187
1232
|
markGcDiskEvidenceIncomplete(evidence, "transcript_unstattable_after_mark");
|
|
1188
1233
|
errors.push({ surface: "blobs", scope: transcriptPath, message: gcDiskErrorText(error) });
|
|
1234
|
+
return;
|
|
1235
|
+
}
|
|
1236
|
+
if (before.size !== after.size || before.mtimeMs !== after.mtimeMs) {
|
|
1237
|
+
// The transcript changed while its references were being read: the
|
|
1238
|
+
// reference set is not bound to a stable snapshot, so it cannot vouch
|
|
1239
|
+
// for any blob. Same posture as the drift rounds exhausting below.
|
|
1240
|
+
markGcDiskEvidenceIncomplete(evidence, "sessions_changed_during_mark");
|
|
1241
|
+
return;
|
|
1189
1242
|
}
|
|
1243
|
+
marked.set(transcriptPath, { size: after.size, mtimeMs: after.mtimeMs });
|
|
1190
1244
|
}
|
|
1191
1245
|
|
|
1192
1246
|
/**
|
|
@@ -1223,6 +1277,53 @@ async function findGcDiskMarkDrift(input: {
|
|
|
1223
1277
|
});
|
|
1224
1278
|
return drifted;
|
|
1225
1279
|
}
|
|
1280
|
+
/**
|
|
1281
|
+
* The sweep's evidence fence: re-walk the store and confirm every transcript
|
|
1282
|
+
* still matches the snapshot its references were read from. Returns false —
|
|
1283
|
+
* after degrading `evidence` to `sessions_changed_during_mark` — when anything
|
|
1284
|
+
* moved since the mark, so the sweep withholds rather than acting on stale
|
|
1285
|
+
* evidence. Run once after the mark loop converges and again immediately
|
|
1286
|
+
* before each removal, because the sweep itself is a window a live session can
|
|
1287
|
+
* write into.
|
|
1288
|
+
*/
|
|
1289
|
+
async function verifyGcDiskMarkFence(input: {
|
|
1290
|
+
sessionsRoot: string;
|
|
1291
|
+
accounted: ReadonlySet<string>;
|
|
1292
|
+
marked: Map<string, GcDiskMarkedTranscript>;
|
|
1293
|
+
evidence: GcDiskEvidence;
|
|
1294
|
+
errors: GcDiskError[];
|
|
1295
|
+
}): Promise<boolean> {
|
|
1296
|
+
const drifted = await findGcDiskMarkDrift({
|
|
1297
|
+
sessionsRoot: input.sessionsRoot,
|
|
1298
|
+
accounted: input.accounted,
|
|
1299
|
+
marked: input.marked,
|
|
1300
|
+
evidence: input.evidence,
|
|
1301
|
+
errors: input.errors,
|
|
1302
|
+
});
|
|
1303
|
+
if (drifted.length === 0) return true;
|
|
1304
|
+
markGcDiskEvidenceIncomplete(input.evidence, "sessions_changed_during_mark");
|
|
1305
|
+
return false;
|
|
1306
|
+
}
|
|
1307
|
+
/**
|
|
1308
|
+
* Confirm the store is observably quiet before the sweep may remove anything.
|
|
1309
|
+
* A single fence pass can still complete inside an inter-append gap of a live
|
|
1310
|
+
* session, so this probes the store across {@link GC_DISK_MARK_QUIESCENCE_MS}
|
|
1311
|
+
* and only reports quiet when every transcript stayed unchanged for the whole
|
|
1312
|
+
* window. A store that keeps moving under the probe never satisfies it, which
|
|
1313
|
+
* is exactly the `sessions_changed_during_mark` condition the sweep must
|
|
1314
|
+
* withhold on instead of reclaiming on evidence it could not prove stable.
|
|
1315
|
+
*/
|
|
1316
|
+
async function confirmGcDiskStoreQuiet(input: {
|
|
1317
|
+
sessionsRoot: string;
|
|
1318
|
+
accounted: ReadonlySet<string>;
|
|
1319
|
+
marked: Map<string, GcDiskMarkedTranscript>;
|
|
1320
|
+
evidence: GcDiskEvidence;
|
|
1321
|
+
errors: GcDiskError[];
|
|
1322
|
+
}): Promise<boolean> {
|
|
1323
|
+
if (!(await verifyGcDiskMarkFence(input))) return false;
|
|
1324
|
+
await Bun.sleep(GC_DISK_MARK_QUIESCENCE_MS);
|
|
1325
|
+
return await verifyGcDiskMarkFence(input);
|
|
1326
|
+
}
|
|
1226
1327
|
|
|
1227
1328
|
/**
|
|
1228
1329
|
* Mark and sweep the content-addressed blob store.
|
|
@@ -1240,6 +1341,14 @@ async function findGcDiskMarkDrift(input: {
|
|
|
1240
1341
|
* grew, and only sweeps once the store stops moving under it. Drift that
|
|
1241
1342
|
* outlasts {@link GC_DISK_MARK_REMARK_ROUNDS} is incomplete evidence, not a
|
|
1242
1343
|
* licence to delete.
|
|
1344
|
+
*
|
|
1345
|
+
* The drift rounds are themselves a window: they can converge on a transcript
|
|
1346
|
+
* that is mid-append, and the sweep then takes time to run. So the mark is
|
|
1347
|
+
* additionally fenced — each transcript's references are bound to a stable
|
|
1348
|
+
* stat snapshot taken around its read, the store must be observed completely
|
|
1349
|
+
* quiet across {@link GC_DISK_MARK_QUIESCENCE_MS} before the sweep acts, and
|
|
1350
|
+
* the fence is re-verified immediately before every removal. Any transcript
|
|
1351
|
+
* that moved at any of those points withholds the whole sweep.
|
|
1243
1352
|
*/
|
|
1244
1353
|
async function runGcDiskBlobs(input: {
|
|
1245
1354
|
surface: GcDiskSurfaceReport;
|
|
@@ -1280,6 +1389,19 @@ async function runGcDiskBlobs(input: {
|
|
|
1280
1389
|
}
|
|
1281
1390
|
}
|
|
1282
1391
|
|
|
1392
|
+
// Gate the sweep on observable quiescence. The rounds can converge inside
|
|
1393
|
+
// an inter-append gap of a live session, and a single clean re-walk would
|
|
1394
|
+
// miss that; a store that keeps moving under the probe never satisfies it.
|
|
1395
|
+
// Only pay for the probe when a blob could actually be reclaimed.
|
|
1396
|
+
if (evidence.complete) {
|
|
1397
|
+
const couldReclaim = blobs.some(
|
|
1398
|
+
blob => !referenced.has(blob.hash) && now - blob.mtimeMs >= GC_DISK_BLOB_GRACE_MS,
|
|
1399
|
+
);
|
|
1400
|
+
if (couldReclaim && !(await confirmGcDiskStoreQuiet({ sessionsRoot, accounted, marked, evidence, errors }))) {
|
|
1401
|
+
markGcDiskEvidenceIncomplete(evidence, "sessions_changed_during_mark");
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1283
1405
|
let withheld = 0;
|
|
1284
1406
|
let withheldBytes = 0;
|
|
1285
1407
|
for (const blob of blobs) {
|
|
@@ -1300,15 +1422,36 @@ async function runGcDiskBlobs(input: {
|
|
|
1300
1422
|
withheld++;
|
|
1301
1423
|
withheldBytes += blob.bytes;
|
|
1302
1424
|
} else {
|
|
1303
|
-
|
|
1304
|
-
|
|
1425
|
+
// Fence again immediately before acting: the sweep itself is a
|
|
1426
|
+
// window, and a transcript that moved since the last check
|
|
1427
|
+
// invalidates the whole mark, not just this blob.
|
|
1428
|
+
if (!(await verifyGcDiskMarkFence({ sessionsRoot, accounted, marked, evidence, errors }))) {
|
|
1429
|
+
record.reason = `withheld_evidence_incomplete: ${evidence.notes.join(", ")}`;
|
|
1430
|
+
record.withheld = true;
|
|
1431
|
+
withheld++;
|
|
1432
|
+
withheldBytes += blob.bytes;
|
|
1433
|
+
} else {
|
|
1434
|
+
record.action = "would_reclaim";
|
|
1435
|
+
record.reason = "unreferenced_by_any_surviving_session";
|
|
1436
|
+
}
|
|
1305
1437
|
}
|
|
1306
1438
|
surface.records.push(record);
|
|
1307
1439
|
|
|
1308
1440
|
if (!prune || record.action !== "would_reclaim") continue;
|
|
1309
|
-
const removal = await removeCanonicalBlob(blob
|
|
1441
|
+
const removal = await removeCanonicalBlob(blob, {
|
|
1442
|
+
// The blob identity check has its own asynchronous read window. Bind the
|
|
1443
|
+
// transcript fence after that check and before unlink, so a reference
|
|
1444
|
+
// appended while the blob is being revalidated withholds the sweep.
|
|
1445
|
+
beforeUnlink: async () => await verifyGcDiskMarkFence({ sessionsRoot, accounted, marked, evidence, errors }),
|
|
1446
|
+
});
|
|
1310
1447
|
if (removal.removed) {
|
|
1311
1448
|
record.action = "reclaimed";
|
|
1449
|
+
} else if (!evidence.complete) {
|
|
1450
|
+
record.action = "keep";
|
|
1451
|
+
record.reason = `withheld_evidence_incomplete: ${evidence.notes.join(", ")}`;
|
|
1452
|
+
record.withheld = true;
|
|
1453
|
+
withheld++;
|
|
1454
|
+
withheldBytes += blob.bytes;
|
|
1312
1455
|
} else if (removal.failed) {
|
|
1313
1456
|
record.action = "reclaim_failed";
|
|
1314
1457
|
record.reason = removal.reason;
|
|
@@ -1328,6 +1471,199 @@ async function runGcDiskBlobs(input: {
|
|
|
1328
1471
|
}
|
|
1329
1472
|
}
|
|
1330
1473
|
|
|
1474
|
+
/**
|
|
1475
|
+
* Which family of GJC-written file a name belongs to, derived from the name's
|
|
1476
|
+
* shape rather than from a list of known tools.
|
|
1477
|
+
*
|
|
1478
|
+
* A published artifact is `<id>.<toolType>.log` and an ID claim is
|
|
1479
|
+
* `.artifact-id-<n>`, so a tool that starts writing `*.newthing.log` tomorrow is
|
|
1480
|
+
* counted the day it ships. Everything else falls back to its extension, so
|
|
1481
|
+
* nothing lands in the scope uncounted.
|
|
1482
|
+
*/
|
|
1483
|
+
function gcDiskArtifactFamily(name: string): string {
|
|
1484
|
+
const toolLog = /^\d+\.([A-Za-z0-9_-]+)\.log$/.exec(name);
|
|
1485
|
+
if (toolLog) return `*.${toolLog[1]}.log`;
|
|
1486
|
+
if (/^\.artifact-id-\d+$/.test(name)) return ".artifact-id-*";
|
|
1487
|
+
const extension = path.extname(name);
|
|
1488
|
+
return extension ? `*${extension}` : "(no extension)";
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
function countGcDiskArtifactFamily(families: Map<string, GcDiskFamilyUsage>, name: string, bytes: number): void {
|
|
1492
|
+
const family = gcDiskArtifactFamily(name);
|
|
1493
|
+
const usage = families.get(family);
|
|
1494
|
+
if (!usage) {
|
|
1495
|
+
families.set(family, { family, count: 1, bytes });
|
|
1496
|
+
return;
|
|
1497
|
+
}
|
|
1498
|
+
usage.count++;
|
|
1499
|
+
usage.bytes += bytes;
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
/**
|
|
1503
|
+
* The per-session artifact directories GJC writes its own tool output into:
|
|
1504
|
+
* `<id>.<tool>.log`, `.artifact-id-<n>` ID claims, evicted-output generations.
|
|
1505
|
+
*
|
|
1506
|
+
* The sessions surface already counts these bytes, but only as one number per
|
|
1507
|
+
* session and only reclaimable by retiring the whole session — so a scope filled
|
|
1508
|
+
* by a retained session's own tool logs was both unattributable and
|
|
1509
|
+
* unreclaimable. They are reported per family always, and reclaimed only for a
|
|
1510
|
+
* transcript that (a) no live surface references, (b) is not the newest
|
|
1511
|
+
* resumable transcript left in its project directory, and (c) holds a file that
|
|
1512
|
+
* has been still for the write-grace window.
|
|
1513
|
+
*
|
|
1514
|
+
* The walk never leaves `sessionsRoot`: directories come from the same
|
|
1515
|
+
* transcript enumeration the sessions surface uses, and inside one only regular
|
|
1516
|
+
* non-symlink files are candidates. A symlink, a subdirectory, or an unstattable
|
|
1517
|
+
* entry is reported as unverified and left alone — never followed, never removed.
|
|
1518
|
+
*/
|
|
1519
|
+
async function runGcDiskArtifacts(input: {
|
|
1520
|
+
surface: GcDiskSurfaceReport;
|
|
1521
|
+
survivors: GcDiskTranscript[];
|
|
1522
|
+
references: GcSessionReferences;
|
|
1523
|
+
agentDir: string;
|
|
1524
|
+
now: number;
|
|
1525
|
+
prune: boolean;
|
|
1526
|
+
errors: GcDiskError[];
|
|
1527
|
+
}): Promise<void> {
|
|
1528
|
+
const { surface, survivors, references, agentDir, now, prune, errors } = input;
|
|
1529
|
+
const sessionIndex = new SessionIndex(agentDir);
|
|
1530
|
+
try {
|
|
1531
|
+
await sessionIndex.withLocked(async () => {
|
|
1532
|
+
// Recomputed over survivors: whichever transcript the sessions surface left
|
|
1533
|
+
// newest in a project directory is the `--continue` target, and a resumed
|
|
1534
|
+
// session still reads its own `artifact://` handles.
|
|
1535
|
+
const newestPerDirectory = new Map<string, GcDiskTranscript>();
|
|
1536
|
+
for (const transcript of survivors) {
|
|
1537
|
+
const current = newestPerDirectory.get(transcript.directory);
|
|
1538
|
+
if (!current || transcript.mtimeMs > current.mtimeMs)
|
|
1539
|
+
newestPerDirectory.set(transcript.directory, transcript);
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
const families = new Map<string, GcDiskFamilyUsage>();
|
|
1543
|
+
let withheld = 0;
|
|
1544
|
+
let withheldBytes = 0;
|
|
1545
|
+
|
|
1546
|
+
for (const transcript of survivors) {
|
|
1547
|
+
const directory = transcript.path.slice(0, -".jsonl".length);
|
|
1548
|
+
let entries: Dirent[];
|
|
1549
|
+
try {
|
|
1550
|
+
entries = await fsp.readdir(directory, { withFileTypes: true });
|
|
1551
|
+
} catch (error) {
|
|
1552
|
+
// No artifact directory is complete evidence: this session wrote none.
|
|
1553
|
+
if (!isEnoent(error)) {
|
|
1554
|
+
errors.push({ surface: "artifacts", scope: directory, message: gcDiskErrorText(error) });
|
|
1555
|
+
}
|
|
1556
|
+
continue;
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
const retained = !references.complete
|
|
1560
|
+
? `reference_scan_incomplete: ${references.notes.join(", ")}`
|
|
1561
|
+
: references.ids.has(transcript.sessionId)
|
|
1562
|
+
? "referenced_by_live_surface"
|
|
1563
|
+
: newestPerDirectory.get(transcript.directory) === transcript
|
|
1564
|
+
? "most_recent_resumable_session"
|
|
1565
|
+
: undefined;
|
|
1566
|
+
|
|
1567
|
+
for (const entry of entries) {
|
|
1568
|
+
if (surface.records.length >= GC_DISK_MAX_WALK_ENTRIES) {
|
|
1569
|
+
errors.push({ surface: "artifacts", scope: directory, message: "artifact_walk_capped" });
|
|
1570
|
+
break;
|
|
1571
|
+
}
|
|
1572
|
+
const target = path.join(directory, entry.name);
|
|
1573
|
+
const record: GcDiskRecord = {
|
|
1574
|
+
surface: "artifacts",
|
|
1575
|
+
id: `${transcript.sessionId}/${entry.name}`,
|
|
1576
|
+
path: target,
|
|
1577
|
+
bytes: 0,
|
|
1578
|
+
age_days: 0,
|
|
1579
|
+
action: "keep",
|
|
1580
|
+
reason: "",
|
|
1581
|
+
};
|
|
1582
|
+
|
|
1583
|
+
// A symlink is never followed and never removed: following one is exactly
|
|
1584
|
+
// how a scope cleanup reaches bytes that are not GJC's to reclaim.
|
|
1585
|
+
// Anything that is not a regular file is unverified state, not an artifact.
|
|
1586
|
+
if (entry.isSymbolicLink() || !entry.isFile()) {
|
|
1587
|
+
record.reason = `unverified_entry: ${entry.isSymbolicLink() ? "symlink" : "not_a_regular_file"}`;
|
|
1588
|
+
record.withheld = true;
|
|
1589
|
+
surface.records.push(record);
|
|
1590
|
+
countGcDiskArtifactFamily(families, entry.name, 0);
|
|
1591
|
+
withheld++;
|
|
1592
|
+
continue;
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
let stat: Stats;
|
|
1596
|
+
try {
|
|
1597
|
+
stat = await fsp.lstat(target);
|
|
1598
|
+
} catch (error) {
|
|
1599
|
+
// Vanished mid-walk is the opposite of withheld: it is already gone.
|
|
1600
|
+
if (isEnoent(error)) continue;
|
|
1601
|
+
const message = gcDiskErrorText(error);
|
|
1602
|
+
errors.push({ surface: "artifacts", scope: target, message });
|
|
1603
|
+
record.reason = `entry_unverifiable: ${message}`;
|
|
1604
|
+
record.error = message;
|
|
1605
|
+
record.withheld = true;
|
|
1606
|
+
surface.records.push(record);
|
|
1607
|
+
countGcDiskArtifactFamily(families, entry.name, 0);
|
|
1608
|
+
withheld++;
|
|
1609
|
+
continue;
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
record.bytes = stat.size;
|
|
1613
|
+
record.age_days = gcDiskAgeDays(now, stat.mtimeMs);
|
|
1614
|
+
surface.records.push(record);
|
|
1615
|
+
countGcDiskArtifactFamily(families, entry.name, stat.size);
|
|
1616
|
+
|
|
1617
|
+
if (retained !== undefined) {
|
|
1618
|
+
record.reason = retained;
|
|
1619
|
+
if (!references.complete) {
|
|
1620
|
+
record.withheld = true;
|
|
1621
|
+
withheld++;
|
|
1622
|
+
withheldBytes += record.bytes;
|
|
1623
|
+
}
|
|
1624
|
+
continue;
|
|
1625
|
+
}
|
|
1626
|
+
if (now - stat.mtimeMs < GC_DISK_BLOB_GRACE_MS) {
|
|
1627
|
+
record.reason = "within_write_grace_window";
|
|
1628
|
+
continue;
|
|
1629
|
+
}
|
|
1630
|
+
record.action = "would_reclaim";
|
|
1631
|
+
record.reason = "unreferenced_by_any_live_session";
|
|
1632
|
+
|
|
1633
|
+
if (!prune) continue;
|
|
1634
|
+
const removal = await removeGcDiskEntry(target, stat);
|
|
1635
|
+
if (removal.removed) record.action = "reclaimed";
|
|
1636
|
+
else if (removal.failed) {
|
|
1637
|
+
record.action = "reclaim_failed";
|
|
1638
|
+
record.reason = removal.reason;
|
|
1639
|
+
record.error = removal.reason;
|
|
1640
|
+
} else {
|
|
1641
|
+
record.action = "keep";
|
|
1642
|
+
record.reason = removal.reason;
|
|
1643
|
+
if (removal.withheld) record.withheld = true;
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
surface.families = [...families.values()].sort(
|
|
1649
|
+
(a, b) => b.bytes - a.bytes || a.family.localeCompare(b.family),
|
|
1650
|
+
);
|
|
1651
|
+
if (!references.complete) {
|
|
1652
|
+
surface.declined = {
|
|
1653
|
+
reason: `reference_scan_incomplete: ${references.notes.join(", ")}`,
|
|
1654
|
+
withheld,
|
|
1655
|
+
withheld_bytes: withheldBytes,
|
|
1656
|
+
};
|
|
1657
|
+
}
|
|
1658
|
+
});
|
|
1659
|
+
} catch (error) {
|
|
1660
|
+
errors.push({
|
|
1661
|
+
surface: "artifacts",
|
|
1662
|
+
scope: surface.root,
|
|
1663
|
+
message: `artifact_liveness_authority_unavailable: ${gcDiskErrorText(error)}`,
|
|
1664
|
+
});
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1331
1667
|
/** Numeric `major.minor.patch` prefix, or undefined when the name is not a version. */
|
|
1332
1668
|
function parseGcDiskVersion(value: string): [number, number, number] | undefined {
|
|
1333
1669
|
const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(value);
|
|
@@ -1649,6 +1985,7 @@ export async function collectGcDiskReport(input: {
|
|
|
1649
1985
|
const surfaces: Record<GcDiskSurface, GcDiskSurfaceReport> = {
|
|
1650
1986
|
sessions: emptyGcDiskSurface("sessions", getSessionsDir(agentDir)),
|
|
1651
1987
|
blobs: emptyGcDiskSurface("blobs", getBlobsDir(agentDir)),
|
|
1988
|
+
artifacts: emptyGcDiskSurface("artifacts", getSessionsDir(agentDir)),
|
|
1652
1989
|
natives: emptyGcDiskSurface("natives", path.join(gjcRoot, "natives")),
|
|
1653
1990
|
backups: emptyGcDiskSurface("backups", path.join(gjcRoot, "backups")),
|
|
1654
1991
|
};
|
|
@@ -1673,6 +2010,15 @@ export async function collectGcDiskReport(input: {
|
|
|
1673
2010
|
prune,
|
|
1674
2011
|
errors,
|
|
1675
2012
|
});
|
|
2013
|
+
await runGcDiskArtifacts({
|
|
2014
|
+
surface: surfaces.artifacts,
|
|
2015
|
+
survivors,
|
|
2016
|
+
references,
|
|
2017
|
+
agentDir,
|
|
2018
|
+
now,
|
|
2019
|
+
prune,
|
|
2020
|
+
errors,
|
|
2021
|
+
});
|
|
1676
2022
|
await runGcDiskNatives({
|
|
1677
2023
|
surface: surfaces.natives,
|
|
1678
2024
|
policy,
|
|
@@ -1687,10 +2033,15 @@ export async function collectGcDiskReport(input: {
|
|
|
1687
2033
|
for (const name of GC_DISK_SURFACES) {
|
|
1688
2034
|
const surface = surfaces[name];
|
|
1689
2035
|
summarizeGcDiskSurface(surface);
|
|
1690
|
-
|
|
2036
|
+
// The artifacts surface re-attributes bytes the sessions surface already
|
|
2037
|
+
// counted inside surviving sessions, so only what it can act on is added:
|
|
2038
|
+
// summing its scan too would report more bytes than exist on disk.
|
|
2039
|
+
if (name !== "artifacts") {
|
|
2040
|
+
totals.scanned_bytes += surface.scanned_bytes;
|
|
2041
|
+
totals.kept_bytes += surface.kept_bytes;
|
|
2042
|
+
}
|
|
1691
2043
|
totals.reclaimable_bytes += surface.reclaimable_bytes;
|
|
1692
2044
|
totals.reclaimed_bytes += surface.reclaimed_bytes;
|
|
1693
|
-
totals.kept_bytes += surface.kept_bytes;
|
|
1694
2045
|
totals.failed += surface.failed;
|
|
1695
2046
|
}
|
|
1696
2047
|
|
|
@@ -1700,6 +2051,7 @@ export async function collectGcDiskReport(input: {
|
|
|
1700
2051
|
const GC_DISK_SURFACE_HEADINGS: Record<GcDiskSurface, string> = {
|
|
1701
2052
|
sessions: "Session transcripts",
|
|
1702
2053
|
blobs: "Content-addressed blobs",
|
|
2054
|
+
artifacts: "Session tool artifacts",
|
|
1703
2055
|
natives: "Cached native versions",
|
|
1704
2056
|
backups: "Update/restore backups",
|
|
1705
2057
|
};
|
|
@@ -1755,11 +2107,18 @@ export function buildGcDiskReportText(disk: GcDiskReport): string {
|
|
|
1755
2107
|
`kept=${surface.kept} (${formatGcDiskBytes(surface.kept_bytes)})`,
|
|
1756
2108
|
);
|
|
1757
2109
|
if (surface.declined) {
|
|
2110
|
+
const reclaimState =
|
|
2111
|
+
surface.reclaimed === 0 ? "reclaimed nothing" : `reclaimed ${surface.reclaimed} before withholding`;
|
|
1758
2112
|
lines.push(
|
|
1759
|
-
` declined:
|
|
2113
|
+
` declined: ${reclaimState} — ${surface.declined.reason} ` +
|
|
1760
2114
|
`(withheld=${surface.declined.withheld} (${formatGcDiskBytes(surface.declined.withheld_bytes)}))`,
|
|
1761
2115
|
);
|
|
1762
2116
|
}
|
|
2117
|
+
// The per-file records below say what may go; the family rollup is what
|
|
2118
|
+
// answers "what filled this scope?", so it is never truncated.
|
|
2119
|
+
for (const family of surface.families ?? []) {
|
|
2120
|
+
lines.push(` family ${family.family} count=${family.count} (${formatGcDiskBytes(family.bytes)})`);
|
|
2121
|
+
}
|
|
1763
2122
|
// Reclaim decisions first: they are what an operator has to audit.
|
|
1764
2123
|
const ranked = [...surface.records].sort(
|
|
1765
2124
|
(a, b) => Number(a.action === "keep") - Number(b.action === "keep") || b.bytes - a.bytes,
|
|
@@ -274,7 +274,7 @@ function isWorktreeDirty(worktreePath: string): boolean {
|
|
|
274
274
|
function resolveOptionalWorktreeName(args: string[], index: number): { name: string | null; nextIndex: number } {
|
|
275
275
|
const next = args[index + 1];
|
|
276
276
|
if (!next) return { name: null, nextIndex: index };
|
|
277
|
-
if (next === "--") return { name: null, nextIndex: index
|
|
277
|
+
if (next === "--") return { name: null, nextIndex: index };
|
|
278
278
|
if (next.startsWith("-")) return { name: null, nextIndex: index };
|
|
279
279
|
return { name: next.trim() || null, nextIndex: index + 1 };
|
|
280
280
|
}
|
|
@@ -285,6 +285,10 @@ export function parseLaunchWorktreeMode(args: string[]): ParsedLaunchWorktreeMod
|
|
|
285
285
|
|
|
286
286
|
for (let index = 0; index < args.length; index += 1) {
|
|
287
287
|
const arg = args[index] ?? "";
|
|
288
|
+
if (arg === "--") {
|
|
289
|
+
remainingArgs.push(...args.slice(index));
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
288
292
|
if (arg === "--worktree" || arg === "-w") {
|
|
289
293
|
const parsed = resolveOptionalWorktreeName(args, index);
|
|
290
294
|
mode = parsed.name
|
|
@@ -2892,13 +2892,16 @@ type TeamTmuxMutation =
|
|
|
2892
2892
|
| { type: "profile-option"; target: string; name: string; value: string }
|
|
2893
2893
|
| { type: "profile-window-option"; target: string; name: string; value: string };
|
|
2894
2894
|
|
|
2895
|
+
const TMUX_ENCODED_LAYOUT_PATTERN = /^[0-9a-f]{4},/i;
|
|
2896
|
+
|
|
2895
2897
|
function readTeamTmuxValue(
|
|
2896
2898
|
config: GjcTeamConfig,
|
|
2897
2899
|
command: "show-options" | "show-window-options",
|
|
2898
2900
|
target: string,
|
|
2899
2901
|
name: string,
|
|
2900
2902
|
): string | undefined {
|
|
2901
|
-
const
|
|
2903
|
+
const flags = command === "show-options" ? "-qv" : "-v";
|
|
2904
|
+
const result = Bun.spawnSync(teamTmuxArgs(config, command, [flags, "-t", target, name]), {
|
|
2902
2905
|
stdout: "pipe",
|
|
2903
2906
|
stderr: "pipe",
|
|
2904
2907
|
});
|
|
@@ -2944,6 +2947,55 @@ function assertTeamTmuxMutationPreproof(config: GjcTeamConfig, operation: TeamTm
|
|
|
2944
2947
|
// alone is never treated as delivery proof.
|
|
2945
2948
|
}
|
|
2946
2949
|
|
|
2950
|
+
function hasMainVerticalPaneTopology(config: GjcTeamConfig, target: string): boolean {
|
|
2951
|
+
const result = Bun.spawnSync(
|
|
2952
|
+
teamTmuxArgs(config, "list-panes", ["-t", target, "-F", "#{pane_left} #{pane_top} #{pane_width} #{pane_height}"]),
|
|
2953
|
+
{ stdout: "pipe", stderr: "pipe" },
|
|
2954
|
+
);
|
|
2955
|
+
if (result.exitCode !== 0) return false;
|
|
2956
|
+
const lines = result.stdout.toString().trim().split("\n");
|
|
2957
|
+
const tokens = lines.map(line => line.trim().split(/\s+/));
|
|
2958
|
+
if (
|
|
2959
|
+
tokens.length < 2 ||
|
|
2960
|
+
tokens.some(values => values.length !== 4 || values.some(value => !/^(?:0|[1-9]\d*)$/.test(value)))
|
|
2961
|
+
)
|
|
2962
|
+
return false;
|
|
2963
|
+
const geometry = tokens.map(values => values.map(value => Number.parseInt(value, 10))) as [
|
|
2964
|
+
number,
|
|
2965
|
+
number,
|
|
2966
|
+
number,
|
|
2967
|
+
number,
|
|
2968
|
+
][];
|
|
2969
|
+
if (geometry.some(values => values.some(value => !Number.isSafeInteger(value) || value < 0))) return false;
|
|
2970
|
+
if (geometry.some(([, , width, height]) => width === 0 || height === 0)) return false;
|
|
2971
|
+
const right = Math.max(...geometry.map(([left, , width]) => left + width));
|
|
2972
|
+
const bottom = Math.max(...geometry.map(([, top, , height]) => top + height));
|
|
2973
|
+
const main = geometry.filter(
|
|
2974
|
+
([left, top, width, height]) => left === 0 && top === 0 && height === bottom && width < right,
|
|
2975
|
+
);
|
|
2976
|
+
if (main.length !== 1) return false;
|
|
2977
|
+
const mainRight = main[0]![0] + main[0]![2];
|
|
2978
|
+
const stack = geometry.filter(pane => pane !== main[0]);
|
|
2979
|
+
const stackLeft = stack[0]![0];
|
|
2980
|
+
if (
|
|
2981
|
+
stack.some(([left, , width]) => left !== stackLeft || left + width !== right) ||
|
|
2982
|
+
stackLeft - mainRight < 0 ||
|
|
2983
|
+
stackLeft - mainRight > 1
|
|
2984
|
+
)
|
|
2985
|
+
return false;
|
|
2986
|
+
const ordered = [...stack].sort((a, b) => a[1] - b[1]);
|
|
2987
|
+
return (
|
|
2988
|
+
ordered[0]![1] === 0 &&
|
|
2989
|
+
ordered.every((pane, index) => {
|
|
2990
|
+
if (index === 0) return true;
|
|
2991
|
+
const previous = ordered[index - 1]!;
|
|
2992
|
+
const rowGap = pane[1] - (previous[1] + previous[3]);
|
|
2993
|
+
return rowGap >= 0 && rowGap <= 1;
|
|
2994
|
+
}) &&
|
|
2995
|
+
ordered.at(-1)![1] + ordered.at(-1)![3] === bottom
|
|
2996
|
+
);
|
|
2997
|
+
}
|
|
2998
|
+
|
|
2947
2999
|
function executeTeamTmuxMutation(
|
|
2948
3000
|
config: GjcTeamConfig,
|
|
2949
3001
|
operation: TeamTmuxMutation,
|
|
@@ -2995,7 +3047,12 @@ function executeTeamTmuxMutation(
|
|
|
2995
3047
|
teamTmuxArgs(config, "display-message", ["-p", "-t", operation.target, "#{window_layout}"]),
|
|
2996
3048
|
{ stdout: "pipe", stderr: "pipe" },
|
|
2997
3049
|
);
|
|
2998
|
-
|
|
3050
|
+
const observedLayout = layout.stdout.toString().trim();
|
|
3051
|
+
if (
|
|
3052
|
+
layout.exitCode !== 0 ||
|
|
3053
|
+
!TMUX_ENCODED_LAYOUT_PATTERN.test(observedLayout) ||
|
|
3054
|
+
!hasMainVerticalPaneTopology(config, operation.target)
|
|
3055
|
+
)
|
|
2999
3056
|
throw new Error("tmux_layout_postproof_failed");
|
|
3000
3057
|
} else if (operation.type === "set-window-option" || operation.type === "profile-window-option") {
|
|
3001
3058
|
if (readTeamTmuxValue(config, "show-window-options", operation.target, operation.name) !== operation.value)
|
|
@@ -1496,8 +1496,27 @@ function normalizeTerminalObservation(request: ObserveTerminalRequest): ObserveT
|
|
|
1496
1496
|
};
|
|
1497
1497
|
}
|
|
1498
1498
|
|
|
1499
|
+
/** One process must not race its own duplicate terminal observers through SQLite. */
|
|
1500
|
+
const liveOwnerTerminalObservations = new Map<string, Promise<OwnerVerdict>>();
|
|
1501
|
+
|
|
1499
1502
|
/** Publish exactly one terminal verdict. Existing valid verdicts always win. */
|
|
1500
1503
|
export async function observeOwnerTerminal(request: ObserveTerminalRequest): Promise<OwnerVerdict> {
|
|
1504
|
+
if (!isObserveTerminalRequest(request)) throw new Error("terminal_observation_invalid");
|
|
1505
|
+
const paths = lifecyclePaths(request.state_dir, request.session_id, request.owner_generation);
|
|
1506
|
+
const existing = liveOwnerTerminalObservations.get(paths.root);
|
|
1507
|
+
if (existing) return existing;
|
|
1508
|
+
const observation = observeOwnerTerminalExclusive(request);
|
|
1509
|
+
liveOwnerTerminalObservations.set(paths.root, observation);
|
|
1510
|
+
try {
|
|
1511
|
+
return await observation;
|
|
1512
|
+
} finally {
|
|
1513
|
+
if (liveOwnerTerminalObservations.get(paths.root) === observation)
|
|
1514
|
+
liveOwnerTerminalObservations.delete(paths.root);
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
/** Publish exactly one terminal verdict. Existing valid verdicts always win. */
|
|
1519
|
+
async function observeOwnerTerminalExclusive(request: ObserveTerminalRequest): Promise<OwnerVerdict> {
|
|
1501
1520
|
if (!isObserveTerminalRequest(request)) throw new Error("terminal_observation_invalid");
|
|
1502
1521
|
const paths = lifecyclePaths(request.state_dir, request.session_id, request.owner_generation);
|
|
1503
1522
|
let observation = normalizeTerminalObservation(request);
|