@gajae-code/coding-agent 0.18.12 → 0.19.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 +467 -0
- package/dist/types/capability/rule.d.ts +7 -2
- package/dist/types/cli/append-system-prompt.d.ts +6 -0
- package/dist/types/cli/args.d.ts +7 -2
- package/dist/types/cli/customize-doctor-precedence.d.ts +16 -0
- package/dist/types/cli/customize-doctor.d.ts +4 -3
- package/dist/types/cli/public-command-errors.d.ts +1 -0
- package/dist/types/cli/root-flags.d.ts +0 -12
- package/dist/types/cli/update-cli.d.ts +7 -2
- package/dist/types/cli-main.d.ts +0 -6
- package/dist/types/commands/launch.d.ts +0 -6
- package/dist/types/commands/migrate.d.ts +1 -1
- package/dist/types/commands/sdk.d.ts +8 -0
- package/dist/types/commit/map-reduce/index.d.ts +4 -4
- package/dist/types/config/autorouting-tier-map.d.ts +23 -1
- package/dist/types/config/config-hot-reload.d.ts +5 -0
- package/dist/types/config/file-lock.d.ts +44 -2
- package/dist/types/config/model-profile-activation.d.ts +19 -5
- package/dist/types/config/model-registry.d.ts +16 -3
- package/dist/types/config/model-resolver.d.ts +5 -4
- package/dist/types/config/settings-schema.d.ts +11 -1
- package/dist/types/coordinator-mcp/server.d.ts +4 -0
- package/dist/types/customization/import.d.ts +2 -0
- package/dist/types/customization/types.d.ts +1 -1
- package/dist/types/dap/session.d.ts +11 -2
- package/dist/types/discovery/helpers.d.ts +7 -2
- package/dist/types/edit/index.d.ts +5 -0
- package/dist/types/edit/modes/patch.d.ts +9 -0
- package/dist/types/eval/py/runtime.d.ts +9 -0
- package/dist/types/export/ttsr.d.ts +19 -0
- package/dist/types/extensibility/extensions/types.d.ts +5 -1
- package/dist/types/extensibility/slash-commands.d.ts +1 -0
- package/dist/types/gjc-runtime/ralplan-current-final.d.ts +54 -0
- package/dist/types/gjc-runtime/ralplan-runtime.d.ts +5 -0
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +24 -0
- package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +6 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
- package/dist/types/harness-control-plane/receipt-spool.d.ts +4 -0
- package/dist/types/hindsight/content.d.ts +5 -6
- package/dist/types/internal-urls/types.d.ts +2 -0
- package/dist/types/lsp/clients/swiftlint-client.d.ts +0 -4
- package/dist/types/main.d.ts +23 -18
- package/dist/types/migrate/action-planner.d.ts +10 -1
- package/dist/types/migrate/adapters/codex.d.ts +1 -1
- package/dist/types/migrate/adapters/index.d.ts +35 -3
- package/dist/types/migrate/hook-module.d.ts +23 -0
- package/dist/types/migrate/report.d.ts +2 -1
- package/dist/types/migrate/types.d.ts +69 -2
- package/dist/types/modes/components/agent-dashboard.d.ts +1 -1
- package/dist/types/modes/components/model-selector.d.ts +43 -0
- package/dist/types/modes/prompt-suggestion-controller.d.ts +1 -1
- package/dist/types/modes/utils/host-status.d.ts +15 -11
- package/dist/types/runtime-mcp/project-host-values.d.ts +11 -5
- package/dist/types/sdk/broker/broker-exit.d.ts +1 -1
- package/dist/types/sdk/broker/broker.d.ts +22 -1
- package/dist/types/sdk/broker/ensure.d.ts +5 -2
- package/dist/types/sdk/broker/hop.d.ts +9 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +22 -0
- package/dist/types/sdk/broker/session-index.d.ts +2 -0
- package/dist/types/sdk/broker/transport.d.ts +2 -0
- package/dist/types/sdk/bus/index.d.ts +21 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +7 -1
- package/dist/types/sdk/bus/telegram-daemon.d.ts +2 -0
- package/dist/types/sdk/bus/threaded-render.d.ts +2 -1
- package/dist/types/sdk/host/control/operations.d.ts +5 -1
- package/dist/types/sdk/host/events.d.ts +4 -0
- package/dist/types/sdk/host/host.d.ts +2 -1
- package/dist/types/sdk/host/prompt-image-upload.d.ts +43 -0
- package/dist/types/sdk/host/session-runtime.d.ts +8 -8
- package/dist/types/sdk/host/surface-policy.d.ts +2 -1
- package/dist/types/sdk/lifecycle/service.d.ts +12 -0
- package/dist/types/sdk/prompt-status.d.ts +11 -4
- package/dist/types/session/agent-session.d.ts +50 -8
- package/dist/types/session/session-manager.d.ts +16 -0
- package/dist/types/skill-state/workflow-mutation-guard.d.ts +13 -1
- package/dist/types/slash-commands/helpers/mcp.d.ts +5 -0
- package/dist/types/ssh/ssh-executor.d.ts +8 -1
- package/dist/types/system-prompt.d.ts +3 -2
- package/dist/types/task/role-body.d.ts +11 -0
- package/dist/types/tools/awaiting-input.d.ts +28 -0
- package/dist/types/tools/bash-allowed-prefixes.d.ts +6 -0
- package/dist/types/tools/bash-skill-urls.d.ts +4 -0
- package/dist/types/tools/bash.d.ts +7 -0
- package/dist/types/tools/descriptors.d.ts +13 -2
- package/dist/types/tools/fetch.d.ts +6 -0
- package/dist/types/tools/irc.d.ts +5 -3
- package/dist/types/tools/tmux-self-injection-guard.d.ts +2 -0
- package/dist/types/tools/ultragoal-ask-guard.d.ts +1 -1
- package/dist/types/tools/write.d.ts +2 -0
- package/dist/types/utils/file-mentions.d.ts +5 -0
- package/dist/types/utils/image-limits.d.ts +7 -0
- package/dist/types/utils/image-loading.d.ts +0 -1
- package/dist/types/utils/open.d.ts +0 -1
- package/dist/types/utils/pasted-image-loading.d.ts +1 -5
- package/dist/types/utils/prompt-suggestion.d.ts +2 -2
- package/dist/types/utils/shell-snapshot.d.ts +2 -0
- package/dist/types/utils/title-generator.d.ts +13 -3
- package/dist/types/web/search/providers/codex.d.ts +6 -7
- package/package.json +9 -8
- package/scripts/acp-conformance-agent.ts +12 -2
- package/scripts/generate-sdk-operation-inventory.ts +16 -0
- package/src/capability/fs.ts +31 -3
- package/src/capability/rule.ts +11 -2
- package/src/cli/append-system-prompt.ts +12 -0
- package/src/cli/args.ts +11 -7
- package/src/cli/customize-doctor-precedence.ts +31 -0
- package/src/cli/customize-doctor.ts +49 -39
- package/src/cli/fast-help.ts +0 -2
- package/src/cli/migrate-cli.ts +14 -3
- package/src/cli/public-command-errors.ts +2 -0
- package/src/cli/root-flags.ts +0 -2
- package/src/cli/update-cli.ts +247 -6
- package/src/commands/harness.ts +25 -15
- package/src/commands/migrate.ts +3 -3
- package/src/commands/sdk.ts +81 -11
- package/src/commit/agentic/agent.ts +6 -6
- package/src/commit/analysis/conventional.ts +14 -5
- package/src/commit/map-reduce/index.ts +7 -7
- package/src/commit/pipeline.ts +12 -12
- package/src/config/autorouting-tier-map.ts +20 -1
- package/src/config/config-file.ts +106 -3
- package/src/config/config-hot-reload.ts +8 -0
- package/src/config/file-lock.ts +1431 -304
- package/src/config/keybindings.ts +154 -7
- package/src/config/model-profile-activation.ts +177 -39
- package/src/config/model-registry.ts +380 -55
- package/src/config/model-resolver.ts +49 -21
- package/src/config/settings-schema.ts +13 -1
- package/src/config/settings.ts +2 -0
- package/src/coordinator-mcp/server.ts +37 -2
- package/src/crash/sanitize.ts +3 -1
- package/src/customization/import.ts +276 -7
- package/src/customization/inventory.ts +24 -8
- package/src/customization/types.ts +1 -0
- package/src/dap/client.ts +3 -2
- package/src/dap/session.ts +161 -36
- package/src/debug/crash-diagnostics.ts +87 -5
- package/src/debug/report-bundle.ts +71 -2
- package/src/discovery/builtin.ts +4 -3
- package/src/discovery/helpers.ts +24 -14
- package/src/edit/index.ts +191 -5
- package/src/edit/modes/patch.ts +27 -0
- package/src/eval/py/runtime.ts +44 -4
- package/src/exec/non-interactive-env.ts +0 -1
- package/src/export/html/index.ts +60 -20
- package/src/export/ttsr.ts +176 -1
- package/src/extensibility/extensions/loader.ts +2 -0
- package/src/extensibility/extensions/ouroboros-ooo-bridge.ts +63 -1
- package/src/extensibility/extensions/types.ts +5 -1
- package/src/extensibility/hooks/loader.ts +10 -2
- package/src/extensibility/plugins/marketplace/manager.ts +36 -10
- package/src/extensibility/slash-commands.ts +5 -0
- package/src/gjc-runtime/gc-runtime.ts +4 -1
- package/src/gjc-runtime/launch-tmux.ts +23 -13
- package/src/gjc-runtime/ralplan-current-final.ts +294 -0
- package/src/gjc-runtime/ralplan-runtime.ts +95 -1
- package/src/gjc-runtime/state-writer.ts +109 -5
- package/src/gjc-runtime/tmux-sessions.ts +93 -13
- package/src/gjc-runtime/ultragoal-change-set.ts +90 -11
- package/src/gjc-runtime/ultragoal-guard.ts +9 -1
- package/src/gjc-runtime/ultragoal-runtime.ts +15 -5
- package/src/harness-control-plane/finalize.ts +11 -0
- package/src/harness-control-plane/receipt-spool.ts +105 -28
- package/src/harness-control-plane/storage.ts +124 -6
- package/src/hindsight/content.ts +116 -16
- package/src/hindsight/mental-models.ts +22 -10
- package/src/hindsight/state.ts +7 -4
- package/src/hooks/native-skill-hook.ts +3 -1
- package/src/internal-urls/artifact-protocol.ts +3 -1
- package/src/internal-urls/docs-index.generated.ts +14 -14
- package/src/internal-urls/local-protocol.ts +28 -3
- package/src/internal-urls/types.ts +2 -0
- package/src/lsp/client.ts +2 -1
- package/src/lsp/clients/biome-client.ts +2 -0
- package/src/lsp/clients/swiftlint-client.ts +2 -0
- package/src/main.ts +330 -92
- package/src/memories/index.ts +25 -2
- package/src/migrate/action-planner.ts +517 -2
- package/src/migrate/adapters/claude-code.ts +141 -3
- package/src/migrate/adapters/codex.ts +57 -5
- package/src/migrate/adapters/index.ts +240 -5
- package/src/migrate/adapters/opencode.ts +4 -0
- package/src/migrate/executor.ts +141 -3
- package/src/migrate/hook-module.ts +193 -0
- package/src/migrate/report.ts +45 -1
- package/src/migrate/types.ts +73 -3
- package/src/modes/acp/acp-agent.ts +10 -1
- package/src/modes/components/agent-dashboard.ts +20 -4
- package/src/modes/components/customization/customization-dashboard.ts +7 -0
- package/src/modes/components/customization/import-wizard.ts +29 -3
- package/src/modes/components/model-selector.ts +157 -103
- package/src/modes/components/welcome.ts +3 -1
- package/src/modes/controllers/command-controller.ts +271 -16
- package/src/modes/controllers/event-controller.ts +1 -1
- package/src/modes/controllers/extension-ui-controller.ts +31 -1
- package/src/modes/controllers/goal-mode-controller.ts +79 -10
- package/src/modes/controllers/input-controller.ts +2 -0
- package/src/modes/controllers/plan-mode-controller.ts +49 -4
- package/src/modes/controllers/runtime-mcp-command-controller.ts +7 -6
- package/src/modes/controllers/selector-controller.ts +14 -1
- package/src/modes/interactive-mode.ts +55 -4
- package/src/modes/prompt-suggestion-controller.ts +1 -1
- package/src/modes/runtime-init.ts +5 -1
- package/src/modes/utils/host-status.ts +21 -10
- package/src/prompts/agent-fragments/browser-aside.md +2 -2
- package/src/prompts/system/irc-incoming.md +3 -1
- package/src/prompts/tools/irc.md +1 -1
- package/src/runtime-mcp/manager.ts +8 -3
- package/src/runtime-mcp/project-host-values.ts +12 -8
- package/src/runtime-mcp/tool-bridge.ts +68 -4
- package/src/runtime-mcp/transports/stdio.ts +2 -1
- package/src/sdk/broker/broker-exit.ts +2 -0
- package/src/sdk/broker/broker.ts +143 -4
- package/src/sdk/broker/ensure.ts +74 -6
- package/src/sdk/broker/hop.ts +13 -1
- package/src/sdk/broker/lifecycle-ledger.ts +3 -0
- package/src/sdk/broker/lifecycle.ts +102 -6
- package/src/sdk/broker/session-index.ts +29 -4
- package/src/sdk/broker/transport.ts +17 -1
- package/src/sdk/bus/chat-daemon-control.ts +2 -2
- package/src/sdk/bus/index.ts +408 -14
- package/src/sdk/bus/session-control.ts +2 -0
- package/src/sdk/bus/slack-daemon.ts +105 -9
- package/src/sdk/bus/telegram-daemon-contract.ts +7 -1
- package/src/sdk/bus/telegram-daemon.ts +10 -2
- package/src/sdk/bus/threaded-render.ts +20 -1
- package/src/sdk/cli/session-cli.ts +2 -0
- package/src/sdk/host/control/dispatch.ts +13 -1
- package/src/sdk/host/control/operations.ts +10 -1
- package/src/sdk/host/events.ts +67 -4
- package/src/sdk/host/host.ts +8 -2
- package/src/sdk/host/prompt-image-upload.ts +438 -0
- package/src/sdk/host/sdk-surface-parity.test.ts +15 -0
- package/src/sdk/host/session-runtime.test.ts +1537 -24
- package/src/sdk/host/session-runtime.ts +332 -36
- package/src/sdk/host/surface-policy.ts +4 -0
- package/src/sdk/host/websocket-transport.lifecycle.test.ts +90 -2
- package/src/sdk/host/websocket-transport.ts +19 -7
- package/src/sdk/lifecycle/service.ts +14 -1
- package/src/sdk/prompt-status.ts +6 -4
- package/src/sdk/protocol/adapter-validation.ts +28 -1
- package/src/sdk/protocol/operation-inventory.generated.json +160 -0
- package/src/sdk/protocol/operation-registry.ts +33 -3
- package/src/sdk/session.ts +123 -36
- package/src/session/agent-session.ts +1614 -329
- package/src/session/history-storage.ts +182 -12
- package/src/session/internal/managed-session-scope.ts +47 -18
- package/src/session/internal/managed-session-storage.ts +71 -1
- package/src/session/session-manager.ts +90 -5
- package/src/session-import/redact.ts +53 -2
- package/src/skill-state/workflow-mutation-guard.ts +1498 -18
- package/src/slash-commands/builtin-registry.ts +6 -3
- package/src/slash-commands/helpers/mcp.ts +11 -3
- package/src/ssh/ssh-executor.ts +9 -2
- package/src/stt/recorder.ts +26 -9
- package/src/system-prompt.ts +61 -16
- package/src/task/executor.ts +12 -8
- package/src/task/role-body.ts +84 -0
- package/src/task/token-log-mode.test.ts +162 -0
- package/src/task/token-log.ts +91 -2
- package/src/tools/archive-reader.ts +650 -37
- package/src/tools/ask.ts +1 -0
- package/src/tools/ast-grep.ts +7 -2
- package/src/tools/auto-generated-guard.ts +12 -3
- package/src/tools/awaiting-input.ts +114 -0
- package/src/tools/bash-allowed-prefixes.ts +61 -1
- package/src/tools/bash-skill-urls.ts +58 -5
- package/src/tools/bash.ts +99 -26
- package/src/tools/browser/tab-worker.ts +39 -13
- package/src/tools/browser.ts +17 -7
- package/src/tools/cron.ts +10 -1
- package/src/tools/debug.ts +9 -2
- package/src/tools/descriptors.test.ts +4 -4
- package/src/tools/descriptors.ts +16 -4
- package/src/tools/eval.ts +15 -0
- package/src/tools/fetch.ts +90 -2
- package/src/tools/gh.ts +27 -12
- package/src/tools/image-gen.ts +20 -6
- package/src/tools/irc.ts +5 -3
- package/src/tools/monitor.ts +9 -1
- package/src/tools/output-meta.ts +51 -3
- package/src/tools/read.ts +9 -2
- package/src/tools/recipe/runner.ts +23 -12
- package/src/tools/render-utils.ts +2 -2
- package/src/tools/search.ts +3 -2
- package/src/tools/ssh.ts +274 -8
- package/src/tools/subagent.ts +10 -4
- package/src/tools/tmux-self-injection-guard.ts +3301 -122
- package/src/tools/tool-catalog.generated.ts +1 -1
- package/src/tools/ultragoal-ask-guard.ts +13 -1
- package/src/tools/write.ts +7 -0
- package/src/tui/tree-list.ts +15 -1
- package/src/utils/commit-message-generator.ts +3 -2
- package/src/utils/external-editor.ts +21 -7
- package/src/utils/file-mentions.ts +46 -6
- package/src/utils/git.ts +38 -7
- package/src/utils/image-limits.ts +8 -0
- package/src/utils/image-loading.ts +1 -1
- package/src/utils/open.ts +73 -4
- package/src/utils/pasted-image-loading.ts +9 -8
- package/src/utils/prompt-suggestion.ts +5 -4
- package/src/utils/shell-snapshot.ts +9 -1
- package/src/utils/title-generator.ts +65 -20
- package/src/web/search/index.ts +2 -1
- package/src/web/search/providers/codex.ts +29 -32
|
@@ -4,6 +4,8 @@ import * as fsSync from "node:fs";
|
|
|
4
4
|
import * as fs from "node:fs/promises";
|
|
5
5
|
import * as path from "node:path";
|
|
6
6
|
import type { Process } from "@gajae-code/natives";
|
|
7
|
+
import { startupProjectEnvSnapshot } from "@gajae-code/utils/env";
|
|
8
|
+
import { canonicalEnvKey, type ProjectEnvSnapshot, projectEnvSnapshot } from "@gajae-code/utils/env-file";
|
|
7
9
|
import { nativeProcessBindings } from "@gajae-code/utils/native-process";
|
|
8
10
|
import { managedSecurityFailureClassification } from "../session/internal/managed-session-storage";
|
|
9
11
|
import { readLinuxProcStartTime, readLinuxProcStartTimeSync } from "./linux-proc";
|
|
@@ -23,6 +25,7 @@ import {
|
|
|
23
25
|
GJC_TMUX_OWNER_SERVER_KEY_ENV,
|
|
24
26
|
GJC_TMUX_OWNER_STATE_DIR_ENV,
|
|
25
27
|
} from "./session-state-sidecar";
|
|
28
|
+
import { trustedCoordinatorEnv } from "./sidecar-bootstrap-env";
|
|
26
29
|
import {
|
|
27
30
|
assertGjcTmuxMutationAuthoritySync,
|
|
28
31
|
bindGjcTmuxProviderAuthority,
|
|
@@ -247,6 +250,81 @@ export function __setMutationServerProofForTests(
|
|
|
247
250
|
mutationServerProofTestDependency = dependency;
|
|
248
251
|
}
|
|
249
252
|
|
|
253
|
+
const TMUX_LAUNCH_IDENTITY_ENV_NAMES = [
|
|
254
|
+
"GJC_SESSION_ID",
|
|
255
|
+
GJC_COORDINATOR_SESSION_ID_ENV,
|
|
256
|
+
GJC_COORDINATOR_SESSION_STATE_FILE_ENV,
|
|
257
|
+
"GJC_TMUX_SESSION",
|
|
258
|
+
] as const;
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Drop project-dotenv copies of the tmux session name, coordinator id, and
|
|
262
|
+
* state-file path. A pin is kept only when every snapshot accepts it, so a
|
|
263
|
+
* declaration Bun loaded before `chdir` is refused even if the launch cwd no
|
|
264
|
+
* longer contains that file. Acceptance uses the shared helper, but the
|
|
265
|
+
* original string stays: callers trim at use, and a later check must see the
|
|
266
|
+
* same value that was accepted.
|
|
267
|
+
*/
|
|
268
|
+
export function trustedTmuxLaunchIdentityEnv(
|
|
269
|
+
env: NodeJS.ProcessEnv,
|
|
270
|
+
snapshots: readonly ProjectEnvSnapshot[],
|
|
271
|
+
): NodeJS.ProcessEnv {
|
|
272
|
+
const denied: string[] = [];
|
|
273
|
+
for (const name of TMUX_LAUNCH_IDENTITY_ENV_NAMES) {
|
|
274
|
+
if (!env[name]) continue;
|
|
275
|
+
for (const snapshot of snapshots) {
|
|
276
|
+
if (trustedCoordinatorEnv(name, env, snapshot) === undefined) {
|
|
277
|
+
denied.push(name);
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (denied.length === 0) return env;
|
|
283
|
+
const next: NodeJS.ProcessEnv = { ...env };
|
|
284
|
+
for (const name of denied) delete next[name];
|
|
285
|
+
return next;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function tmuxLaunchIdentityEnv(env: NodeJS.ProcessEnv, cwd: string): NodeJS.ProcessEnv {
|
|
289
|
+
return trustedTmuxLaunchIdentityEnv(env, [projectEnvSnapshot(cwd), startupProjectEnvSnapshot()]);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Session id the launched child must see. A trusted pin is passed through.
|
|
294
|
+
* A rejected project pin is replaced by the same fallback the launch plan uses
|
|
295
|
+
* for the session root, which also stops Bun from reloading that dotenv value
|
|
296
|
+
* (`GJC_TMUX_LAUNCHED=1` skips the planner filter). An omitted id stays omitted.
|
|
297
|
+
*/
|
|
298
|
+
export function resolvedChildGjcSessionId(input: {
|
|
299
|
+
trusted: string | undefined;
|
|
300
|
+
inherited: string | undefined;
|
|
301
|
+
projectDeclared: boolean;
|
|
302
|
+
fallback: string;
|
|
303
|
+
}): string | undefined {
|
|
304
|
+
const trusted = input.trusted?.trim();
|
|
305
|
+
if (trusted) return trusted;
|
|
306
|
+
if (!input.inherited?.trim() && !input.projectDeclared) return undefined;
|
|
307
|
+
const fallback = input.fallback.trim();
|
|
308
|
+
return fallback.length > 0 ? fallback : undefined;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function childGjcSessionIdAssignment(
|
|
312
|
+
env: NodeJS.ProcessEnv,
|
|
313
|
+
identityEnv: NodeJS.ProcessEnv,
|
|
314
|
+
cwd: string,
|
|
315
|
+
fallbackSessionId: string,
|
|
316
|
+
): Record<string, string> {
|
|
317
|
+
const snapshot = projectEnvSnapshot(cwd);
|
|
318
|
+
const key = canonicalEnvKey("GJC_SESSION_ID");
|
|
319
|
+
const value = resolvedChildGjcSessionId({
|
|
320
|
+
trusted: identityEnv.GJC_SESSION_ID,
|
|
321
|
+
inherited: env.GJC_SESSION_ID,
|
|
322
|
+
projectDeclared: snapshot.values[key] !== undefined || snapshot.dynamic.has(key),
|
|
323
|
+
fallback: fallbackSessionId,
|
|
324
|
+
});
|
|
325
|
+
return value === undefined ? {} : { GJC_SESSION_ID: value };
|
|
326
|
+
}
|
|
327
|
+
|
|
250
328
|
function psmuxAuthorityFromEnv(env: NodeJS.ProcessEnv): ProviderAuthority | null {
|
|
251
329
|
const stateDir = env[GJC_TMUX_OWNER_STATE_DIR_ENV]?.trim();
|
|
252
330
|
const sessionId = env[GJC_COORDINATOR_SESSION_ID_ENV]?.trim();
|
|
@@ -515,11 +593,14 @@ function canonicalProviderStateDirs(cwd: string): string[] {
|
|
|
515
593
|
}
|
|
516
594
|
|
|
517
595
|
function psmuxAuthorityEnvironments(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv[] {
|
|
518
|
-
const
|
|
596
|
+
const identityEnv = tmuxLaunchIdentityEnv(env, process.cwd());
|
|
597
|
+
const explicitAuthority = psmuxAuthorityFromEnv(identityEnv);
|
|
519
598
|
if (explicitAuthority) return [environmentForProviderAuthority(env, explicitAuthority)];
|
|
520
599
|
const explicitStateDir =
|
|
521
|
-
|
|
522
|
-
(
|
|
600
|
+
identityEnv[GJC_TMUX_OWNER_STATE_DIR_ENV]?.trim() ??
|
|
601
|
+
(identityEnv[GJC_COORDINATOR_SESSION_STATE_FILE_ENV]
|
|
602
|
+
? path.dirname(identityEnv[GJC_COORDINATOR_SESSION_STATE_FILE_ENV])
|
|
603
|
+
: "");
|
|
523
604
|
const ambient = resolveGjcTmuxBinary({ env });
|
|
524
605
|
if (gjcTmuxAuthorityPlatform() === "win32") {
|
|
525
606
|
const ambientAvailable = path.isAbsolute(ambient.command)
|
|
@@ -669,17 +750,15 @@ export function createGjcTmuxSession(
|
|
|
669
750
|
if (launch && !validManagedLaunchSpec(launch, platform)) throw new Error("gjc_tmux_managed_launch_spec_invalid");
|
|
670
751
|
const provider = resolveGjcTmuxProviderContext({ env, platform });
|
|
671
752
|
const tmuxCommand = provider.command;
|
|
672
|
-
const launchEnvironment = launch ? { ...env, GJC_TMUX_SESSION: launch.childSessionId } : env;
|
|
673
|
-
const sessionName = buildGjcTmuxSessionName(launchEnvironment);
|
|
674
753
|
const cwd = launch?.cwd ?? process.cwd();
|
|
675
|
-
const
|
|
754
|
+
const identityEnv = tmuxLaunchIdentityEnv(env, cwd);
|
|
755
|
+
const launchEnvironment = launch ? { ...identityEnv, GJC_TMUX_SESSION: launch.childSessionId } : identityEnv;
|
|
756
|
+
const sessionName = buildGjcTmuxSessionName(launchEnvironment);
|
|
757
|
+
const sessionId = launch?.childSessionId ?? (identityEnv[GJC_COORDINATOR_SESSION_ID_ENV]?.trim() || sessionName);
|
|
758
|
+
const filteredGjcSessionId = identityEnv.GJC_SESSION_ID?.trim() || sessionId;
|
|
676
759
|
const stateFile =
|
|
677
|
-
(!launch ?
|
|
678
|
-
tmuxRuntimeSessionPath(
|
|
679
|
-
cwd,
|
|
680
|
-
launch?.childSessionId ?? (env.GJC_SESSION_ID?.trim() || sessionId),
|
|
681
|
-
buildGjcTmuxSessionSlug(sessionName),
|
|
682
|
-
);
|
|
760
|
+
(!launch ? identityEnv[GJC_COORDINATOR_SESSION_STATE_FILE_ENV]?.trim() : undefined) ||
|
|
761
|
+
tmuxRuntimeSessionPath(cwd, launch?.childSessionId ?? filteredGjcSessionId, buildGjcTmuxSessionSlug(sessionName));
|
|
683
762
|
const stateDir = (platform === "win32" ? path.win32 : path).dirname(stateFile);
|
|
684
763
|
const generation = crypto.randomUUID();
|
|
685
764
|
const authority = bindGjcTmuxProviderAuthority(provider, { stateDir, sessionId, generation });
|
|
@@ -690,6 +769,7 @@ export function createGjcTmuxSession(
|
|
|
690
769
|
[GJC_TMUX_OWNER_SERVER_KEY_ENV]: tmuxCommand,
|
|
691
770
|
[GJC_COORDINATOR_SESSION_ID_ENV]: sessionId,
|
|
692
771
|
[GJC_COORDINATOR_SESSION_STATE_FILE_ENV]: stateFile,
|
|
772
|
+
...childGjcSessionIdAssignment(env, identityEnv, cwd, filteredGjcSessionId),
|
|
693
773
|
...(launch
|
|
694
774
|
? {
|
|
695
775
|
[MANAGED_OWNER_RUN_ID_ENV]: crypto.randomUUID(),
|
|
@@ -699,7 +779,7 @@ export function createGjcTmuxSession(
|
|
|
699
779
|
: {}),
|
|
700
780
|
};
|
|
701
781
|
const childEnvironment: Record<string, string> = { ...(launch?.env ?? {}), ...managedEnvironment };
|
|
702
|
-
const executionEnv = { ...
|
|
782
|
+
const executionEnv = { ...identityEnv, ...managedEnvironment };
|
|
703
783
|
const shellQuote = (value: string): string => `'${value.replace(/'/g, `'\\''`)}'`;
|
|
704
784
|
const command =
|
|
705
785
|
platform === "win32"
|
|
@@ -128,15 +128,30 @@ async function hashAddedFiles(cwd: string, paths: readonly UltragoalChangeSetPat
|
|
|
128
128
|
|
|
129
129
|
export async function spawnText(
|
|
130
130
|
command: string[],
|
|
131
|
-
options: { cwd: string; timeoutMs?: number },
|
|
131
|
+
options: { cwd: string; timeoutMs?: number; stdin?: string },
|
|
132
132
|
): Promise<{ ok: boolean; stdout: string; stderr: string }> {
|
|
133
133
|
try {
|
|
134
|
-
const proc = Bun.spawn(command, { cwd: options.cwd, stdout: "pipe", stderr: "pipe" });
|
|
134
|
+
const proc = Bun.spawn(command, { cwd: options.cwd, stdin: "pipe", stdout: "pipe", stderr: "pipe" });
|
|
135
135
|
const timeout = setTimeout(() => proc.kill(), options.timeoutMs ?? 5000);
|
|
136
|
+
// Drain both pipes while the stdin write runs: a child that never reads
|
|
137
|
+
// stdin blocks the write, and a child that fills stdout before draining
|
|
138
|
+
// stdin blocks the read.
|
|
139
|
+
const stdinWrite = (async () => {
|
|
140
|
+
const stdin = proc.stdin;
|
|
141
|
+
if (!stdin || options.stdin === undefined) return;
|
|
142
|
+
try {
|
|
143
|
+
stdin.write(options.stdin);
|
|
144
|
+
await stdin.end();
|
|
145
|
+
} catch {
|
|
146
|
+
// The child closed stdin or the timer killed it; the exit code
|
|
147
|
+
// already reports the failure.
|
|
148
|
+
}
|
|
149
|
+
})();
|
|
136
150
|
const [stdoutBytes, stderrBytes, exitCode] = await Promise.all([
|
|
137
151
|
new Response(proc.stdout).arrayBuffer(),
|
|
138
152
|
new Response(proc.stderr).arrayBuffer(),
|
|
139
153
|
proc.exited,
|
|
154
|
+
stdinWrite,
|
|
140
155
|
]);
|
|
141
156
|
clearTimeout(timeout);
|
|
142
157
|
let stdout: string;
|
|
@@ -152,6 +167,37 @@ export async function spawnText(
|
|
|
152
167
|
}
|
|
153
168
|
}
|
|
154
169
|
|
|
170
|
+
/**
|
|
171
|
+
* Derive the empty tree object ID for the given repository, which depends on the
|
|
172
|
+
* repository's object format (SHA-1 vs SHA-256). A repository with no commits has
|
|
173
|
+
* no refs and no merge base, so the empty tree is the only authoritative baseline:
|
|
174
|
+
* every tracked, staged, and untracked file is an addition on top of it.
|
|
175
|
+
*
|
|
176
|
+
* @param cwd The repository root directory
|
|
177
|
+
* @returns The empty tree object ID (format-specific), or the SHA-1 ID if derivation fails
|
|
178
|
+
*/
|
|
179
|
+
async function getEmptyTreeId(cwd: string): Promise<string> {
|
|
180
|
+
// Compute the empty tree object ID by hashing an empty tree.
|
|
181
|
+
// This respects the repository's object format (SHA-1 or SHA-256).
|
|
182
|
+
const result = await spawnText(["git", "hash-object", "-t", "tree", "--stdin"], {
|
|
183
|
+
cwd,
|
|
184
|
+
timeoutMs: 3000,
|
|
185
|
+
stdin: "", // Empty input for empty tree
|
|
186
|
+
});
|
|
187
|
+
if (result.ok && result.stdout.trim()) {
|
|
188
|
+
return result.stdout.trim();
|
|
189
|
+
}
|
|
190
|
+
// Fallback to SHA-1 empty tree ID if derivation fails
|
|
191
|
+
// (e.g., git command not available, permission denied, etc.)
|
|
192
|
+
return "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The canonical empty tree object for SHA-1 repositories. Use getEmptyTreeId()
|
|
197
|
+
* to derive the format-specific ID for a given repository.
|
|
198
|
+
*/
|
|
199
|
+
export const GIT_EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
200
|
+
|
|
155
201
|
export async function resolveGitBase(cwd: string, branch?: string): Promise<string> {
|
|
156
202
|
if (branch) {
|
|
157
203
|
const exists = await spawnText(["git", "rev-parse", "--verify", branch], { cwd, timeoutMs: 3000 });
|
|
@@ -163,11 +209,31 @@ export async function resolveGitBase(cwd: string, branch?: string): Promise<stri
|
|
|
163
209
|
// and mis-attributes other people's changes to this story (e.g. falsely
|
|
164
210
|
// tripping change-scoped gates). Among existing candidates, pick the one
|
|
165
211
|
// whose merge-base with HEAD is closest to HEAD (fewest commits ahead).
|
|
166
|
-
const candidates = [
|
|
212
|
+
const candidates = [
|
|
213
|
+
"origin/dev",
|
|
214
|
+
"dev",
|
|
215
|
+
"origin/develop",
|
|
216
|
+
"develop",
|
|
217
|
+
"origin/main",
|
|
218
|
+
"origin/master",
|
|
219
|
+
"main",
|
|
220
|
+
"master",
|
|
221
|
+
];
|
|
222
|
+
const currentBranch = await spawnText(["git", "symbolic-ref", "--quiet", "HEAD"], { cwd, timeoutMs: 3000 });
|
|
223
|
+
const currentRef = currentBranch.ok ? currentBranch.stdout.trim() : undefined;
|
|
167
224
|
let best: { ref: string; ahead: number } | undefined;
|
|
225
|
+
|
|
168
226
|
for (const candidate of candidates) {
|
|
169
|
-
const exists = await spawnText(["git", "rev-parse", "--verify", candidate], {
|
|
227
|
+
const exists = await spawnText(["git", "rev-parse", "--symbolic-full-name", "--verify", candidate], {
|
|
228
|
+
cwd,
|
|
229
|
+
timeoutMs: 3000,
|
|
230
|
+
});
|
|
170
231
|
if (!exists.ok) continue;
|
|
232
|
+
// Exclude only the checked-out local ref, not distinct refs at the same
|
|
233
|
+
// commit or remote-tracking refs with the same branch name. Comparing
|
|
234
|
+
// the checked-out ref to HEAD hides committed work as a complete empty
|
|
235
|
+
// capture; without an independent baseline, resolution must fail closed.
|
|
236
|
+
if (currentRef && exists.stdout.trim() === currentRef) continue;
|
|
171
237
|
const mergeBase = await spawnText(["git", "merge-base", "HEAD", candidate], { cwd, timeoutMs: 3000 });
|
|
172
238
|
if (!mergeBase.ok || !mergeBase.stdout.trim()) continue;
|
|
173
239
|
const count = await spawnText(["git", "rev-list", "--count", `${mergeBase.stdout.trim()}..HEAD`], {
|
|
@@ -285,16 +351,26 @@ export async function computeCheckpointChangeSet(cwd: string): Promise<Ultragoal
|
|
|
285
351
|
}
|
|
286
352
|
const captureWitness = await repositoryStateWitness(cwd);
|
|
287
353
|
await repositoryStateWitnessTestHook?.("after-initial", cwd);
|
|
288
|
-
|
|
354
|
+
// A freshly initialized repository has no commits, so `HEAD`, every branch
|
|
355
|
+
// candidate, and any merge base are unresolvable. The empty tree is the only
|
|
356
|
+
// authoritative baseline there; refusing outright would strand a run whose
|
|
357
|
+
// implementation is entirely untracked. Everything on disk then counts as an
|
|
358
|
+
// addition, which is hashed like any other added file and never committed.
|
|
359
|
+
const headPresent = await spawnText(["git", "rev-parse", "--verify", "HEAD"], { cwd, timeoutMs: 3000 });
|
|
360
|
+
const unborn = !headPresent.ok;
|
|
361
|
+
const baseRef = unborn ? await getEmptyTreeId(cwd) : await resolveGitBase(cwd);
|
|
289
362
|
const base = baseRef;
|
|
290
|
-
const mergeBase =
|
|
363
|
+
const mergeBase = unborn
|
|
364
|
+
? { ok: false, stdout: "", stderr: "repository has no commits" }
|
|
365
|
+
: await spawnText(["git", "merge-base", "HEAD", baseRef], { cwd, timeoutMs: 3000 });
|
|
366
|
+
const diffRange = unborn ? baseRef : `${base}...HEAD`;
|
|
291
367
|
const [committed, unstaged, staged, untracked, stat, committedDiff, unstagedDiff, stagedDiff] = await Promise.all([
|
|
292
|
-
spawnText(["git", "diff", "--name-status", "-z",
|
|
368
|
+
spawnText(["git", "diff", "--name-status", "-z", unborn ? baseRef : diffRange], { cwd, timeoutMs: 5000 }),
|
|
293
369
|
spawnText(["git", "diff", "--name-status", "-z"], { cwd, timeoutMs: 5000 }),
|
|
294
370
|
spawnText(["git", "diff", "--cached", "--name-status", "-z"], { cwd, timeoutMs: 5000 }),
|
|
295
371
|
spawnText(["git", "ls-files", "--others", "--exclude-standard", "-z"], { cwd, timeoutMs: 5000 }),
|
|
296
|
-
spawnText(["git", "diff", "--stat",
|
|
297
|
-
spawnText(["git", "diff",
|
|
372
|
+
spawnText(["git", "diff", "--stat", diffRange], { cwd, timeoutMs: 5000 }),
|
|
373
|
+
spawnText(["git", "diff", diffRange], { cwd, timeoutMs: 5000 }),
|
|
298
374
|
spawnText(["git", "diff"], { cwd, timeoutMs: 5000 }),
|
|
299
375
|
spawnText(["git", "diff", "--cached"], { cwd, timeoutMs: 5000 }),
|
|
300
376
|
]);
|
|
@@ -382,7 +458,10 @@ async function repositoryStateWitness(cwd: string): Promise<string | undefined>
|
|
|
382
458
|
spawnText(["git", "ls-files", "-z"], { cwd, timeoutMs: 5000 }),
|
|
383
459
|
spawnText(["git", "ls-files", "--others", "--exclude-standard", "-z"], { cwd, timeoutMs: 5000 }),
|
|
384
460
|
]);
|
|
385
|
-
|
|
461
|
+
// An unborn repository has no HEAD yet; the remaining reads still describe its
|
|
462
|
+
// full state, so witness it as an empty head rather than failing the capture.
|
|
463
|
+
if (!status.ok || !tracked.ok || !untracked.ok) return undefined;
|
|
464
|
+
const headToken = head.ok ? head.stdout.trim() : "unborn";
|
|
386
465
|
const paths = [
|
|
387
466
|
...new Set([
|
|
388
467
|
...parseGitUntrackedPaths(tracked.stdout).map(row => row.path),
|
|
@@ -417,7 +496,7 @@ async function repositoryStateWitness(cwd: string): Promise<string | undefined>
|
|
|
417
496
|
} catch {
|
|
418
497
|
return undefined;
|
|
419
498
|
}
|
|
420
|
-
return `${
|
|
499
|
+
return `${headToken}\u0000${status.stdout}\u0000sha256:${content.digest("hex")}`;
|
|
421
500
|
}
|
|
422
501
|
|
|
423
502
|
export function parseUnifiedDiffPaths(diff: string): UltragoalChangeSetPath[] {
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
type UltragoalPlan,
|
|
26
26
|
type UltragoalReceiptKind,
|
|
27
27
|
} from "./ultragoal-runtime";
|
|
28
|
+
import { readUltragoalSuccessionFence } from "./ultragoal-succession";
|
|
28
29
|
|
|
29
30
|
export type UltragoalGuardState =
|
|
30
31
|
| "inactive"
|
|
@@ -823,7 +824,14 @@ async function consumeUltragoalNudge(input: {
|
|
|
823
824
|
if (!sessionId) return { nudged: false };
|
|
824
825
|
const plan = await readUltragoalPlan(input.cwd, sessionId);
|
|
825
826
|
if (!plan) return { nudged: false };
|
|
826
|
-
const
|
|
827
|
+
const fence = await readUltragoalSuccessionFence(input.cwd, sessionId);
|
|
828
|
+
// A goal handed off to a successor run is not this run's resolvable work. Skip
|
|
829
|
+
// fenced goals while selecting, so a retained source-owned goal can still receive
|
|
830
|
+
// the nudge (and keep aggregate drop guarded) without spending budget on the handoff.
|
|
831
|
+
const target = selectUltragoalNudgeTarget(plan, {
|
|
832
|
+
currentGoalObjective: input.currentGoal?.objective,
|
|
833
|
+
excludedGoalIds: fence?.selectedGoalIds,
|
|
834
|
+
});
|
|
827
835
|
if (!target) return { nudged: false };
|
|
828
836
|
const { budget } = await resolveUltragoalNudgeBudget(input.cwd, input.agentDir);
|
|
829
837
|
const outcome = await recordUltragoalNudgeIfBudgetRemaining({
|
|
@@ -82,6 +82,7 @@ import {
|
|
|
82
82
|
import {
|
|
83
83
|
assertUltragoalAdoptionPublished,
|
|
84
84
|
assertUltragoalGoalNotFenced,
|
|
85
|
+
readUltragoalSuccessionFence,
|
|
85
86
|
runUltragoalSuccessionCommand,
|
|
86
87
|
} from "./ultragoal-succession";
|
|
87
88
|
import {
|
|
@@ -472,22 +473,27 @@ export async function resolveUltragoalNudgeBudget(
|
|
|
472
473
|
*/
|
|
473
474
|
export function selectUltragoalNudgeTarget(
|
|
474
475
|
plan: UltragoalPlan,
|
|
475
|
-
options: { currentGoalObjective?: string; retryFailed?: boolean } = {},
|
|
476
|
+
options: { currentGoalObjective?: string; retryFailed?: boolean; excludedGoalIds?: readonly string[] } = {},
|
|
476
477
|
): UltragoalNudgeTarget | null {
|
|
477
478
|
const objective = options.currentGoalObjective?.trim();
|
|
479
|
+
const isExcluded = (goalId: string): boolean => options.excludedGoalIds?.includes(goalId) ?? false;
|
|
478
480
|
if (objective) {
|
|
479
481
|
const matched = plan.goals.find(
|
|
480
|
-
goal => goal.objective.trim() === objective && SCHEDULABLE_STATUSES.has(goal.status),
|
|
482
|
+
goal => goal.objective.trim() === objective && SCHEDULABLE_STATUSES.has(goal.status) && !isExcluded(goal.id),
|
|
481
483
|
);
|
|
482
484
|
if (matched) return { goalId: matched.id, targetKind: "story" };
|
|
483
485
|
}
|
|
484
|
-
const
|
|
486
|
+
const schedulableGoals = options.excludedGoalIds?.length
|
|
487
|
+
? plan.goals.filter(goal => !isExcluded(goal.id))
|
|
488
|
+
: plan.goals;
|
|
489
|
+
const next = selectNextSchedulableGoal(schedulableGoals, options.retryFailed === true);
|
|
485
490
|
if (next) return { goalId: next.id, targetKind: "story" };
|
|
486
491
|
const completion = getUltragoalRunCompletionState(plan, { retryFailed: options.retryFailed });
|
|
487
492
|
if (completion.needsFinalAggregateReceipt) {
|
|
488
493
|
const required = requiredUltragoalGoals(plan);
|
|
489
494
|
const finalGoal = required.at(-1);
|
|
490
|
-
if (finalGoal
|
|
495
|
+
if (finalGoal && !isExcluded(finalGoal.id))
|
|
496
|
+
return { goalId: finalGoal.id, targetKind: "final_aggregate_receipt" };
|
|
491
497
|
}
|
|
492
498
|
return null;
|
|
493
499
|
}
|
|
@@ -1125,7 +1131,11 @@ export async function getUltragoalStatus(
|
|
|
1125
1131
|
else if (counts.active > 0) status = "active";
|
|
1126
1132
|
else if (counts.failed > 0) status = "failed";
|
|
1127
1133
|
else if (counts.blocked > 0 || counts.review_blocked > 0) status = "blocked";
|
|
1128
|
-
const
|
|
1134
|
+
const fence = await readUltragoalSuccessionFence(cwd, resolvedSessionId);
|
|
1135
|
+
const nudgeTarget = selectUltragoalNudgeTarget(plan, {
|
|
1136
|
+
currentGoalObjective: currentGoal?.objective,
|
|
1137
|
+
excludedGoalIds: fence?.selectedGoalIds,
|
|
1138
|
+
});
|
|
1129
1139
|
let nudgeFields: Partial<UltragoalStatusSummary> = {};
|
|
1130
1140
|
if (nudgeTarget) {
|
|
1131
1141
|
const { budget } = await resolveUltragoalNudgeBudget(cwd, agentDir);
|
|
@@ -14,6 +14,7 @@ import { randomBytes } from "node:crypto";
|
|
|
14
14
|
import { readdirSync, readFileSync } from "node:fs";
|
|
15
15
|
import { readFile } from "node:fs/promises";
|
|
16
16
|
import { type LinuxProcPidProbeResult, probeLinuxProcPidSync } from "../gjc-runtime/linux-proc";
|
|
17
|
+
import { checkTmuxSelfInjection } from "../tools/tmux-self-injection-guard";
|
|
17
18
|
import {
|
|
18
19
|
buildReceipt,
|
|
19
20
|
type CompletionEvidence,
|
|
@@ -558,6 +559,16 @@ export function defaultFinalizeChecks(
|
|
|
558
559
|
return {
|
|
559
560
|
async runValidation(spec, signal) {
|
|
560
561
|
if (signal?.aborted) throw new ValidationObservationUncertainError(spec.command, workspace, signal.reason);
|
|
562
|
+
// Same preflight as BashTool: refuse before spawn so a plan command cannot type into this pane.
|
|
563
|
+
const injection = await checkTmuxSelfInjection(spec.command, {
|
|
564
|
+
env: process.env,
|
|
565
|
+
cwd: workspace,
|
|
566
|
+
executionEnv: process.env,
|
|
567
|
+
});
|
|
568
|
+
if (signal?.aborted) throw new ValidationObservationUncertainError(spec.command, workspace, signal.reason);
|
|
569
|
+
if (injection.block) {
|
|
570
|
+
return { exactCommand: spec.command, cwd: workspace, exitStatus: 1, pass: false };
|
|
571
|
+
}
|
|
561
572
|
let proc: Bun.Subprocess<"ignore", "pipe", "pipe">;
|
|
562
573
|
try {
|
|
563
574
|
proc = Bun.spawn(["bash", "-lc", spec.command], {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { constants as fsConstants } from "node:fs";
|
|
2
3
|
import * as fs from "node:fs/promises";
|
|
3
4
|
import * as path from "node:path";
|
|
5
|
+
import { canonicalEnvKey, loadedProjectEnvSnapshot } from "@gajae-code/utils/dirs";
|
|
4
6
|
import { withFileLock } from "../config/file-lock";
|
|
5
7
|
import type { ReceiptEnvelope } from "./receipts";
|
|
6
8
|
|
|
@@ -21,6 +23,42 @@ export interface ReceiptSpoolAppendResult {
|
|
|
21
23
|
const receiptSpoolDirStorage = new AsyncLocalStorage<string | undefined>();
|
|
22
24
|
const spoolQueues = new Map<string, Promise<void>>();
|
|
23
25
|
const noop = (): void => undefined;
|
|
26
|
+
const NOFOLLOW = fsConstants.O_NOFOLLOW ?? 0;
|
|
27
|
+
|
|
28
|
+
/** True only when an exception's `code` is `ENOENT`. Other codes propagate. */
|
|
29
|
+
export function isEnoent(error: unknown): boolean {
|
|
30
|
+
return (error as NodeJS.ErrnoException).code === "ENOENT";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Bun copies the cwd dotenv into `process.env` before module init. The startup
|
|
35
|
+
* project snapshot is the declaration set trusted directory overrides use: a
|
|
36
|
+
* path that resolves like that declaration, or a dynamic declaration, is not a
|
|
37
|
+
* spool directory. A shell or `--receipt-spool-dir` value that resolves
|
|
38
|
+
* somewhere else is kept.
|
|
39
|
+
*/
|
|
40
|
+
function projectDeclaredReceiptSpoolDir(raw: string): boolean {
|
|
41
|
+
const snapshot = loadedProjectEnvSnapshot();
|
|
42
|
+
const key = canonicalEnvKey(RECEIPT_SPOOL_DIR_ENV);
|
|
43
|
+
const projectValue = snapshot.values[key];
|
|
44
|
+
if (projectValue === undefined) return false;
|
|
45
|
+
if (snapshot.dynamic.has(key)) return true;
|
|
46
|
+
const declared = projectValue.trim();
|
|
47
|
+
const candidate = raw.trim();
|
|
48
|
+
if (!declared || !candidate) return declared === candidate;
|
|
49
|
+
return path.resolve(declared) === path.resolve(candidate);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function adoptReceiptSpoolDir(raw: string | undefined): string | undefined {
|
|
53
|
+
if (raw === undefined || projectDeclaredReceiptSpoolDir(raw)) return undefined;
|
|
54
|
+
const trimmed = raw.trim();
|
|
55
|
+
return trimmed ? path.resolve(trimmed) : undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A spool directory the startup project snapshot does not declare. */
|
|
59
|
+
export function trustedReceiptSpoolDir(raw: string | undefined): string | undefined {
|
|
60
|
+
return adoptReceiptSpoolDir(raw);
|
|
61
|
+
}
|
|
24
62
|
|
|
25
63
|
interface ReceiptSpoolCursorCache {
|
|
26
64
|
mtimeMs: number;
|
|
@@ -44,10 +82,15 @@ export function resetReceiptSpoolCursorCacheForTesting(): void {
|
|
|
44
82
|
|
|
45
83
|
async function spoolStat(spoolFile: string): Promise<{ mtimeMs: number; size: number } | undefined> {
|
|
46
84
|
try {
|
|
47
|
-
const
|
|
48
|
-
|
|
85
|
+
const handle = await fs.open(spoolFile, fsConstants.O_RDONLY | NOFOLLOW);
|
|
86
|
+
try {
|
|
87
|
+
const stats = await handle.stat();
|
|
88
|
+
return { mtimeMs: stats.mtimeMs, size: stats.size };
|
|
89
|
+
} finally {
|
|
90
|
+
await handle.close();
|
|
91
|
+
}
|
|
49
92
|
} catch (error) {
|
|
50
|
-
if ((error
|
|
93
|
+
if (isEnoent(error)) return undefined;
|
|
51
94
|
throw error;
|
|
52
95
|
}
|
|
53
96
|
}
|
|
@@ -64,15 +107,17 @@ async function cacheHighestCursor(spoolFile: string, highest: bigint): Promise<v
|
|
|
64
107
|
export async function withReceiptSpoolDir<T>(spoolDir: string, fn: () => Promise<T>): Promise<T> {
|
|
65
108
|
const trimmed = spoolDir.trim();
|
|
66
109
|
if (!trimmed) throw new Error("receipt_spool_dir_empty");
|
|
67
|
-
const resolved =
|
|
110
|
+
const resolved = adoptReceiptSpoolDir(trimmed);
|
|
111
|
+
// Owner input repeats process.env. A project-declared directory must not
|
|
112
|
+
// become the active spool via that copy.
|
|
113
|
+
if (!resolved) return fn();
|
|
68
114
|
return receiptSpoolDirStorage.run(resolved, fn);
|
|
69
115
|
}
|
|
70
116
|
|
|
71
117
|
export function resolveReceiptSpoolDir(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
72
118
|
const active = receiptSpoolDirStorage.getStore();
|
|
73
119
|
if (active !== undefined) return active;
|
|
74
|
-
|
|
75
|
-
return raw ? path.resolve(raw) : undefined;
|
|
120
|
+
return adoptReceiptSpoolDir(env[RECEIPT_SPOOL_DIR_ENV]);
|
|
76
121
|
}
|
|
77
122
|
|
|
78
123
|
export function receiptSpoolPath(spoolDir: string): string {
|
|
@@ -95,30 +140,56 @@ export function formatReceiptSpoolCursor(cursor: bigint): string {
|
|
|
95
140
|
|
|
96
141
|
export async function readHighestReceiptSpoolCursor(spoolDir: string): Promise<bigint> {
|
|
97
142
|
const spoolFile = receiptSpoolPath(spoolDir);
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
143
|
+
let handle: fs.FileHandle;
|
|
144
|
+
try {
|
|
145
|
+
handle = await fs.open(spoolFile, fsConstants.O_RDONLY | NOFOLLOW);
|
|
146
|
+
} catch (error) {
|
|
147
|
+
if (isEnoent(error)) {
|
|
148
|
+
cursorCache.delete(spoolFile);
|
|
149
|
+
return 0n;
|
|
150
|
+
}
|
|
151
|
+
throw error;
|
|
102
152
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
153
|
+
try {
|
|
154
|
+
const stats = await handle.stat();
|
|
155
|
+
const revision = { mtimeMs: stats.mtimeMs, size: stats.size };
|
|
156
|
+
const cached = cursorCache.get(spoolFile);
|
|
157
|
+
if (cached && sameSpoolRevision(cached, revision)) return cached.highest;
|
|
158
|
+
|
|
159
|
+
fullScanCount++;
|
|
160
|
+
const raw = await handle.readFile({ encoding: "utf8" });
|
|
161
|
+
let highest = 0n;
|
|
162
|
+
for (const line of raw.split("\n")) {
|
|
163
|
+
const trimmed = line.trim();
|
|
164
|
+
if (!trimmed) continue;
|
|
165
|
+
try {
|
|
166
|
+
const parsed = JSON.parse(trimmed) as { cursor?: unknown };
|
|
167
|
+
const cursor = parseCursor(parsed.cursor);
|
|
168
|
+
if (cursor !== undefined && cursor > highest) highest = cursor;
|
|
169
|
+
} catch {
|
|
170
|
+
// A crash may leave a torn tail; a full scan ignores it and re-establishes the cache.
|
|
171
|
+
}
|
|
118
172
|
}
|
|
173
|
+
cursorCache.set(spoolFile, { ...revision, highest });
|
|
174
|
+
return highest;
|
|
175
|
+
} finally {
|
|
176
|
+
await handle.close();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function tightenSpoolDir(dir: string): Promise<void> {
|
|
181
|
+
if (process.platform === "win32") return;
|
|
182
|
+
let handle: fs.FileHandle;
|
|
183
|
+
try {
|
|
184
|
+
handle = await fs.open(dir, fsConstants.O_RDONLY | (fsConstants.O_DIRECTORY ?? 0) | NOFOLLOW);
|
|
185
|
+
} catch {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
await handle.chmod(0o700);
|
|
190
|
+
} finally {
|
|
191
|
+
await handle.close();
|
|
119
192
|
}
|
|
120
|
-
cursorCache.set(spoolFile, { ...revision, highest });
|
|
121
|
-
return highest;
|
|
122
193
|
}
|
|
123
194
|
|
|
124
195
|
async function enqueueSpoolAppend<T>(spoolFile: string, task: () => Promise<T>): Promise<T> {
|
|
@@ -142,13 +213,19 @@ export async function appendReceiptToSpool(
|
|
|
142
213
|
const spoolFile = receiptSpoolPath(resolvedDir);
|
|
143
214
|
return enqueueSpoolAppend(spoolFile, async () => {
|
|
144
215
|
await fs.mkdir(resolvedDir, { recursive: true, mode: 0o700 });
|
|
216
|
+
await tightenSpoolDir(resolvedDir);
|
|
145
217
|
return withFileLock(
|
|
146
218
|
spoolFile,
|
|
147
219
|
async () => {
|
|
148
220
|
const cursor = formatReceiptSpoolCursor((await readHighestReceiptSpoolCursor(resolvedDir)) + 1n);
|
|
149
221
|
const record: ReceiptSpoolRecord = { cursor, envelope };
|
|
150
|
-
const handle = await fs.open(
|
|
222
|
+
const handle = await fs.open(
|
|
223
|
+
spoolFile,
|
|
224
|
+
fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_APPEND | NOFOLLOW,
|
|
225
|
+
0o600,
|
|
226
|
+
);
|
|
151
227
|
try {
|
|
228
|
+
if (process.platform !== "win32") await handle.chmod(0o600);
|
|
152
229
|
await handle.writeFile(`${JSON.stringify(record)}\n`, "utf8");
|
|
153
230
|
await handle.sync();
|
|
154
231
|
} finally {
|