@gajae-code/coding-agent 0.12.0 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +82 -1
- package/dist/types/async/job-manager.d.ts +17 -3
- package/dist/types/config/settings-schema.d.ts +54 -0
- package/dist/types/exa/mcp-client.d.ts +10 -1
- package/dist/types/exec/bash-executor.d.ts +45 -12
- package/dist/types/extensibility/extensions/types.d.ts +16 -2
- package/dist/types/extensibility/gjc-plugins/index.d.ts +12 -2
- package/dist/types/extensibility/gjc-plugins/installer.d.ts +63 -7
- package/dist/types/extensibility/gjc-plugins/lifecycle-reconciliation.d.ts +55 -0
- package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +64 -0
- package/dist/types/extensibility/gjc-plugins/runtime-quarantine.d.ts +67 -0
- package/dist/types/extensibility/gjc-plugins/session-validation.d.ts +3 -1
- package/dist/types/extensibility/gjc-plugins/types.d.ts +124 -0
- package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
- package/dist/types/gjc-runtime/ralplan-runtime.d.ts +18 -0
- package/dist/types/gjc-runtime/team-launch.d.ts +1 -1
- package/dist/types/gjc-runtime/team-runtime.d.ts +25 -0
- package/dist/types/gjc-runtime/team-worker-heartbeat.d.ts +43 -0
- package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +10 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +31 -80
- package/dist/types/modes/acp/acp-agent.d.ts +2 -19
- package/dist/types/modes/components/gjc-bundle-settings.d.ts +34 -0
- package/dist/types/modes/components/notifications-settings-editor.d.ts +1 -0
- package/dist/types/modes/components/session-observer-overlay.d.ts +18 -0
- package/dist/types/modes/components/settings-selector.d.ts +9 -0
- package/dist/types/modes/components/status-line/gh.d.ts +5 -0
- package/dist/types/modes/components/transcript-viewer-overlay.d.ts +18 -1
- package/dist/types/reminders/star-reminder.d.ts +3 -13
- package/dist/types/runtime-mcp/manager.d.ts +1 -0
- package/dist/types/sdk/acp/adapter.d.ts +3 -1
- package/dist/types/sdk/broker/transport.d.ts +2 -0
- package/dist/types/sdk/bus/attachment-registry.d.ts +1 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +4 -1
- package/dist/types/sdk/bus/config.d.ts +6 -2
- package/dist/types/sdk/bus/index.d.ts +29 -0
- package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +8 -1
- package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +4 -0
- package/dist/types/sdk/bus/reconciliation-store.d.ts +7 -3
- package/dist/types/sdk/bus/rich-draft.d.ts +1 -0
- package/dist/types/sdk/bus/rich-render.d.ts +2 -0
- package/dist/types/sdk/bus/telegram-adoption-intent.d.ts +165 -0
- package/dist/types/sdk/bus/telegram-cli.d.ts +12 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +11 -4
- package/dist/types/sdk/bus/telegram-daemon.d.ts +25 -1
- package/dist/types/sdk/bus/telegram-reference.d.ts +11 -1
- package/dist/types/sdk/bus/threaded-render.d.ts +3 -2
- package/dist/types/sdk/bus/topic-registry.d.ts +11 -1
- package/dist/types/sdk/host/query/revision-store.d.ts +2 -0
- package/dist/types/sdk/host/reverse-leases.d.ts +1 -1
- package/dist/types/sdk/prompt-status.d.ts +23 -3
- package/dist/types/sdk/session.d.ts +6 -0
- package/dist/types/session/agent-session.d.ts +38 -2
- package/dist/types/session/artifacts.d.ts +8 -0
- package/dist/types/session/blob-store.d.ts +57 -4
- package/dist/types/session/internal/managed-session-storage.d.ts +8 -1
- package/dist/types/session/session-manager.d.ts +23 -6
- package/dist/types/session/session-storage.d.ts +4 -0
- package/dist/types/session/streaming-output.d.ts +36 -7
- package/dist/types/setup/credential-import.d.ts +12 -0
- package/dist/types/skill-state/active-state.d.ts +12 -0
- package/dist/types/skill-state/workflow-hud.d.ts +6 -0
- package/dist/types/task/executor.d.ts +3 -2
- package/dist/types/task/index.d.ts +3 -1
- package/dist/types/task/provider-retry-status.d.ts +8 -0
- package/dist/types/task/receipt.d.ts +7 -1
- package/dist/types/task/types.d.ts +23 -0
- package/dist/types/tools/ask-answer-registry.d.ts +2 -2
- package/dist/types/tools/bash-interactive.d.ts +4 -1
- package/dist/types/tools/bash.d.ts +3 -1
- package/dist/types/tools/image-gen.d.ts +4 -0
- package/dist/types/tools/index.d.ts +4 -0
- package/dist/types/tools/output-meta.d.ts +23 -2
- package/dist/types/tools/path-utils.d.ts +1 -0
- package/dist/types/tools/read.d.ts +0 -6
- package/dist/types/tools/review.d.ts +5 -7
- package/dist/types/tools/subagent.d.ts +2 -0
- package/dist/types/utils/gh.d.ts +13 -0
- package/dist/types/utils/image-resize.d.ts +1 -0
- package/dist/types/web/search/providers/kimi.d.ts +2 -0
- package/dist/types/web/search/providers/searxng.d.ts +7 -0
- package/dist/types/web/search/providers/xai.d.ts +2 -0
- package/package.json +10 -8
- package/scripts/acp-conformance-agent.ts +218 -0
- package/scripts/capture-gjc-bundle-settings.ts +306 -0
- package/scripts/generate-sdk-operation-inventory.ts +3 -0
- package/scripts/resident-memory-bench.ts +1341 -0
- package/scripts/run-acp-conformance.ts +285 -0
- package/src/async/job-manager.ts +78 -20
- package/src/cli/fast-help.ts +1 -1
- package/src/cli/plugin-cli.ts +176 -18
- package/src/commands/team.ts +19 -1
- package/src/config/file-lock.ts +11 -2
- package/src/config/settings-schema.ts +51 -0
- package/src/coordinator-mcp/question-gate-codec.ts +1 -1
- package/src/coordinator-mcp/server.ts +5 -12
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -0
- package/src/defaults/gjc/skills/ralplan/SKILL.md +19 -16
- package/src/defaults/gjc/skills/team/SKILL.md +2 -0
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +37 -24
- package/src/defaults/gjc/skills/ultragoal/validation-batch-contracts.md +12 -0
- package/src/defaults/gjc-defaults.ts +4 -4
- package/src/exa/mcp-client.ts +12 -3
- package/src/exec/bash-executor.ts +144 -27
- package/src/extensibility/extensions/runner.ts +15 -0
- package/src/extensibility/extensions/types.ts +18 -1
- package/src/extensibility/gjc-plugins/constrained-hooks.ts +8 -2
- package/src/extensibility/gjc-plugins/index.ts +19 -2
- package/src/extensibility/gjc-plugins/installer.ts +189 -50
- package/src/extensibility/gjc-plugins/lifecycle-reconciliation.ts +199 -0
- package/src/extensibility/gjc-plugins/lifecycle.ts +626 -0
- package/src/extensibility/gjc-plugins/observability.ts +3 -3
- package/src/extensibility/gjc-plugins/prompt-appendix.ts +5 -1
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +12 -2
- package/src/extensibility/gjc-plugins/runtime-quarantine.ts +121 -0
- package/src/extensibility/gjc-plugins/schema.ts +84 -12
- package/src/extensibility/gjc-plugins/session-validation.ts +9 -3
- package/src/extensibility/gjc-plugins/types.ts +141 -0
- package/src/extensibility/gjc-plugins/validation.ts +9 -0
- package/src/gjc-runtime/launch-tmux.ts +25 -6
- package/src/gjc-runtime/managed-owner-admission.ts +1 -1
- package/src/gjc-runtime/ralplan-runtime.ts +411 -17
- package/src/gjc-runtime/team-launch.ts +8 -0
- package/src/gjc-runtime/team-runtime.ts +140 -32
- package/src/gjc-runtime/team-worker-heartbeat.ts +149 -0
- package/src/gjc-runtime/tmux-owner-isolation.ts +10 -0
- package/src/gjc-runtime/tmux-sessions.ts +58 -13
- package/src/gjc-runtime/ultragoal-runtime.ts +401 -611
- package/src/gjc-runtime/workflow-manifest.generated.json +8 -99
- package/src/gjc-runtime/workflow-manifest.ts +4 -68
- package/src/internal-urls/docs-index.generated.ts +15 -15
- package/src/modes/DESIGN.md +51 -0
- package/src/modes/acp/acp-agent.ts +298 -186
- package/src/modes/components/gjc-bundle-settings.ts +508 -0
- package/src/modes/components/notifications-settings-editor.ts +12 -1
- package/src/modes/components/oauth-selector.ts +68 -30
- package/src/modes/components/session-observer-overlay.ts +305 -86
- package/src/modes/components/settings-selector.ts +56 -5
- package/src/modes/components/status-line/gh.ts +16 -0
- package/src/modes/components/tool-status-header.ts +3 -14
- package/src/modes/components/transcript-viewer-overlay.ts +99 -24
- package/src/modes/controllers/command-controller.ts +4 -0
- package/src/modes/controllers/extension-ui-controller.ts +7 -0
- package/src/modes/controllers/selector-controller.ts +45 -0
- package/src/modes/runtime-init.ts +3 -0
- package/src/prompts/agent-fragments/ralplan-persistence.md +4 -3
- package/src/prompts/system/eager-todo.md +8 -4
- package/src/prompts/system/system-prompt.md +11 -4
- package/src/prompts/tools/bash.md +5 -5
- package/src/prompts/tools/search-tool-bm25.md +5 -0
- package/src/prompts/tools/task.md +1 -1
- package/src/reminders/star-reminder.ts +4 -42
- package/src/runtime-mcp/manager.ts +19 -4
- package/src/sdk/acp/adapter.ts +22 -6
- package/src/sdk/broker/transport.ts +35 -3
- package/src/sdk/bus/attachment-registry.ts +5 -1
- package/src/sdk/bus/chat-daemon-cli.ts +4 -39
- package/src/sdk/bus/chat-daemon-control.ts +6 -3
- package/src/sdk/bus/config.ts +18 -2
- package/src/sdk/bus/index.ts +661 -126
- package/src/sdk/bus/kind-aware-reconciliation.ts +148 -73
- package/src/sdk/bus/lifecycle-commands.ts +4 -1
- package/src/sdk/bus/lifecycle-control-runtime.ts +403 -88
- package/src/sdk/bus/reconciliation-store.ts +95 -16
- package/src/sdk/bus/rich-draft.ts +1 -1
- package/src/sdk/bus/rich-render.ts +2 -2
- package/src/sdk/bus/telegram-adoption-intent.ts +574 -0
- package/src/sdk/bus/telegram-cli.ts +33 -8
- package/src/sdk/bus/telegram-daemon-cli.ts +1 -0
- package/src/sdk/bus/telegram-daemon-contract.ts +11 -4
- package/src/sdk/bus/telegram-daemon.ts +916 -23
- package/src/sdk/bus/telegram-reference.ts +40 -3
- package/src/sdk/bus/threaded-render.ts +48 -3
- package/src/sdk/bus/topic-registry.ts +20 -0
- package/src/sdk/host/query/revision-store.ts +36 -2
- package/src/sdk/host/reverse-leases.ts +44 -7
- package/src/sdk/prompt-status.ts +16 -3
- package/src/sdk/protocol/operation-inventory.generated.json +22 -0
- package/src/sdk/session.ts +88 -9
- package/src/session/agent-session.ts +822 -172
- package/src/session/artifacts.ts +22 -0
- package/src/session/auth-broker-config.ts +10 -3
- package/src/session/blob-store.ts +915 -24
- package/src/session/internal/managed-session-scope.ts +44 -6
- package/src/session/internal/managed-session-storage.ts +48 -6
- package/src/session/session-manager.ts +1614 -435
- package/src/session/session-storage.ts +9 -1
- package/src/session/streaming-output.ts +238 -68
- package/src/setup/credential-import.ts +75 -13
- package/src/skill-state/active-state.ts +32 -0
- package/src/skill-state/workflow-hud.ts +17 -0
- package/src/skill-state/workflow-mutation-guard.ts +496 -2
- package/src/task/executor.ts +122 -39
- package/src/task/index.ts +23 -14
- package/src/task/provider-retry-status.ts +16 -0
- package/src/task/receipt.ts +23 -7
- package/src/task/render.ts +6 -2
- package/src/task/types.ts +108 -0
- package/src/tools/ask-answer-registry.ts +12 -6
- package/src/tools/ask.ts +86 -55
- package/src/tools/ast-edit.ts +1 -0
- package/src/tools/ast-grep.ts +1 -0
- package/src/tools/bash-interactive.ts +8 -3
- package/src/tools/bash.ts +503 -60
- package/src/tools/find.ts +1 -0
- package/src/tools/image-gen.ts +29 -5
- package/src/tools/index.ts +4 -0
- package/src/tools/output-meta.ts +108 -14
- package/src/tools/path-utils.ts +3 -2
- package/src/tools/read.ts +18 -0
- package/src/tools/review.ts +5 -7
- package/src/tools/search.ts +1 -0
- package/src/tools/subagent-render.ts +6 -2
- package/src/tools/subagent.ts +6 -0
- package/src/tools/telegram-send.ts +6 -3
- package/src/utils/gh.ts +39 -0
- package/src/utils/image-resize.ts +3 -0
- package/src/web/search/providers/anthropic.ts +4 -4
- package/src/web/search/providers/kimi.ts +15 -5
- package/src/web/search/providers/searxng.ts +47 -4
- package/src/web/search/providers/xai.ts +7 -2
- package/dist/types/gjc-runtime/ultragoal-pipeline.d.ts +0 -23
- package/src/defaults/gjc/skills/ultragoal/pipeline-validation-contracts.md +0 -30
- package/src/gjc-runtime/ultragoal-pipeline.ts +0 -306
|
@@ -551,6 +551,40 @@ export const GJC_TEAM_API_OPERATIONS = [
|
|
|
551
551
|
"write-task-approval",
|
|
552
552
|
] as const;
|
|
553
553
|
|
|
554
|
+
export type GjcTeamApiOperation = (typeof GJC_TEAM_API_OPERATIONS)[number];
|
|
555
|
+
|
|
556
|
+
export class UnknownGjcTeamApiOperationError extends Error {
|
|
557
|
+
readonly code = "unknown_team_api_operation";
|
|
558
|
+
readonly operation: string;
|
|
559
|
+
readonly suggestions: readonly string[];
|
|
560
|
+
|
|
561
|
+
constructor(operation: string, suggestions: readonly string[]) {
|
|
562
|
+
const guidance =
|
|
563
|
+
suggestions.length > 0
|
|
564
|
+
? `did you mean ${suggestions.join(" or ")}?`
|
|
565
|
+
: "run gjc team api --help for supported operations";
|
|
566
|
+
super(`unknown_team_api_operation:${operation}; ${guidance}`);
|
|
567
|
+
this.name = "UnknownGjcTeamApiOperationError";
|
|
568
|
+
this.operation = operation;
|
|
569
|
+
this.suggestions = suggestions;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function isGjcTeamApiOperation(operation: string): operation is GjcTeamApiOperation {
|
|
574
|
+
return (GJC_TEAM_API_OPERATIONS as readonly string[]).includes(operation);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function unknownGjcTeamApiOperationSuggestions(operation: string): readonly string[] {
|
|
578
|
+
if (operation === "heartbeat") return ["read-worker-heartbeat", "update-worker-heartbeat"];
|
|
579
|
+
if (operation === "get-task") return ["read-task"];
|
|
580
|
+
return [];
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function resolveGjcTeamApiOperation(operation: string): GjcTeamApiOperation {
|
|
584
|
+
if (isGjcTeamApiOperation(operation)) return operation;
|
|
585
|
+
throw new UnknownGjcTeamApiOperationError(operation, unknownGjcTeamApiOperationSuggestions(operation));
|
|
586
|
+
}
|
|
587
|
+
|
|
554
588
|
function currentTimeMs(): number {
|
|
555
589
|
return gjcTeamRuntimeTestSeams?.nowMs?.() ?? Date.now();
|
|
556
590
|
}
|
|
@@ -1494,6 +1528,7 @@ async function relaunchWorkerPaneForMemoryGuard(input: {
|
|
|
1494
1528
|
startupAckPath: string;
|
|
1495
1529
|
replacementToken: string;
|
|
1496
1530
|
startupAckTimeoutMs: number;
|
|
1531
|
+
env: NodeJS.ProcessEnv;
|
|
1497
1532
|
}): Promise<string> {
|
|
1498
1533
|
if (input.config.dry_run)
|
|
1499
1534
|
return `%memory-guard-${input.worker.id}-${stableHash(`${input.worker.id}:${now()}`).slice(0, 8)}`;
|
|
@@ -1502,6 +1537,7 @@ async function relaunchWorkerPaneForMemoryGuard(input: {
|
|
|
1502
1537
|
input.worker,
|
|
1503
1538
|
input.platform,
|
|
1504
1539
|
`Send startup ACK before resuming: gjc team api worker-startup-ack --input '{"team_name":"${input.config.team_name}","worker_id":"${input.worker.id}","protocol_version":"1","replacement_token":"${input.replacementToken}"}' --json. ${GJC_TEAM_CONTINUATION_PROMPT}`,
|
|
1540
|
+
input.env,
|
|
1505
1541
|
);
|
|
1506
1542
|
const workerCwd = input.worker.worktree_path ?? input.config.leader.cwd;
|
|
1507
1543
|
const useSendKeysFallback = shouldDispatchWorkerWithSendKeys(input.config.tmux_command, input.platform);
|
|
@@ -1602,7 +1638,12 @@ async function applyWorkerMemoryGuardUnlocked(input: {
|
|
|
1602
1638
|
allowAutomaticAction?: boolean;
|
|
1603
1639
|
replacementToken?: string;
|
|
1604
1640
|
dir: string;
|
|
1605
|
-
|
|
1641
|
+
/**
|
|
1642
|
+
* Acquire the team task-mutation fence only for the blocked-state transition.
|
|
1643
|
+
* Must not be held across the successor startup-ack wait: concurrent
|
|
1644
|
+
* `worker-startup-ack` must publish while replacement is in flight.
|
|
1645
|
+
*/
|
|
1646
|
+
withTaskMutation: <T>(fn: (capability: GjcTeamTaskMutationCapability) => Promise<T>) => Promise<T>;
|
|
1606
1647
|
}): Promise<Record<string, unknown>> {
|
|
1607
1648
|
const dir = input.dir;
|
|
1608
1649
|
const config = await readConfig(dir);
|
|
@@ -1782,16 +1823,18 @@ async function applyWorkerMemoryGuardUnlocked(input: {
|
|
|
1782
1823
|
ledger = retried.ledger;
|
|
1783
1824
|
await writeWorkerMemoryGuardLedger(dir, ledger);
|
|
1784
1825
|
if (retried.finalBlocked)
|
|
1785
|
-
await
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1826
|
+
await input.withTaskMutation(taskMutation =>
|
|
1827
|
+
finalizeWorkerMemoryGuardBlockedState({
|
|
1828
|
+
teamName: input.teamName,
|
|
1829
|
+
dir,
|
|
1830
|
+
worker,
|
|
1831
|
+
task,
|
|
1832
|
+
taskMutation,
|
|
1833
|
+
reason: checkpoint.reason,
|
|
1834
|
+
cwd: input.cwd,
|
|
1835
|
+
env: input.env,
|
|
1836
|
+
}),
|
|
1837
|
+
);
|
|
1795
1838
|
await appendWorkerMemoryGuardAction({
|
|
1796
1839
|
dir,
|
|
1797
1840
|
teamName: input.teamName,
|
|
@@ -1853,6 +1896,7 @@ async function applyWorkerMemoryGuardUnlocked(input: {
|
|
|
1853
1896
|
startupAckPath,
|
|
1854
1897
|
replacementToken,
|
|
1855
1898
|
startupAckTimeoutMs,
|
|
1899
|
+
env: input.env,
|
|
1856
1900
|
});
|
|
1857
1901
|
} catch (error) {
|
|
1858
1902
|
await restorePredecessorStartupState();
|
|
@@ -1868,16 +1912,18 @@ async function applyWorkerMemoryGuardUnlocked(input: {
|
|
|
1868
1912
|
ledger = retried.ledger;
|
|
1869
1913
|
await writeWorkerMemoryGuardLedger(dir, ledger);
|
|
1870
1914
|
if (retried.finalBlocked)
|
|
1871
|
-
await
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1915
|
+
await input.withTaskMutation(taskMutation =>
|
|
1916
|
+
finalizeWorkerMemoryGuardBlockedState({
|
|
1917
|
+
teamName: input.teamName,
|
|
1918
|
+
dir,
|
|
1919
|
+
worker,
|
|
1920
|
+
task,
|
|
1921
|
+
taskMutation,
|
|
1922
|
+
reason,
|
|
1923
|
+
cwd: input.cwd,
|
|
1924
|
+
env: input.env,
|
|
1925
|
+
}),
|
|
1926
|
+
);
|
|
1881
1927
|
await appendWorkerMemoryGuardAction({
|
|
1882
1928
|
dir,
|
|
1883
1929
|
teamName: input.teamName,
|
|
@@ -2043,12 +2089,17 @@ async function applyWorkerMemoryGuardUnlocked(input: {
|
|
|
2043
2089
|
}
|
|
2044
2090
|
|
|
2045
2091
|
async function applyWorkerMemoryGuard(
|
|
2046
|
-
input: Omit<Parameters<typeof applyWorkerMemoryGuardUnlocked>[0], "dir" | "
|
|
2092
|
+
input: Omit<Parameters<typeof applyWorkerMemoryGuardUnlocked>[0], "dir" | "withTaskMutation">,
|
|
2047
2093
|
): Promise<Record<string, unknown>> {
|
|
2048
2094
|
const dir = await findTeamDir(input.teamName, input.cwd, input.env);
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2095
|
+
// Do not hold withGjcTeamTaskMutation across relaunchWorkerPaneForMemoryGuard's
|
|
2096
|
+
// startup-ack poll (default 120s). That fence would serialize concurrent
|
|
2097
|
+
// worker-startup-ack publication and hang selector-replacement under CI load.
|
|
2098
|
+
return applyWorkerMemoryGuardUnlocked({
|
|
2099
|
+
...input,
|
|
2100
|
+
dir,
|
|
2101
|
+
withTaskMutation: fn => withGjcTeamTaskMutation(taskStore(dir), fn),
|
|
2102
|
+
});
|
|
2052
2103
|
}
|
|
2053
2104
|
async function readPhase(dir: string): Promise<GjcTeamPhase> {
|
|
2054
2105
|
try {
|
|
@@ -2484,7 +2535,11 @@ function tagTmuxSessionAsGjcLeader(tmuxCommand: string, sessionName: string): bo
|
|
|
2484
2535
|
return result.exitCode === 0;
|
|
2485
2536
|
}
|
|
2486
2537
|
|
|
2487
|
-
function readCurrentTmuxLeaderContext(
|
|
2538
|
+
function readCurrentTmuxLeaderContext(
|
|
2539
|
+
tmuxCommand: string,
|
|
2540
|
+
env: NodeJS.ProcessEnv,
|
|
2541
|
+
adoptUnmanagedSession = true,
|
|
2542
|
+
): GjcTmuxLeaderContext {
|
|
2488
2543
|
if (Bun.which(tmuxCommand) === null)
|
|
2489
2544
|
throw new Error(buildTeamTmuxLeaderRequirementMessage(`tmux_not_installed:${tmuxCommand}`));
|
|
2490
2545
|
// Prefer the explicit GJC-managed session name propagated by `gjc --tmux`
|
|
@@ -2517,7 +2572,7 @@ function readCurrentTmuxLeaderContext(tmuxCommand: string, env: NodeJS.ProcessEn
|
|
|
2517
2572
|
const [sessionName = "", windowIndex = ""] = sessionAndWindow.split(":");
|
|
2518
2573
|
if (!sessionName || !windowIndex || !leaderPaneId.startsWith("%"))
|
|
2519
2574
|
throw new Error(buildTeamTmuxLeaderRequirementMessage(`invalid_tmux_context:${result.stdout.toString().trim()}`));
|
|
2520
|
-
if (readGjcTmuxProfileValue(tmuxCommand, sessionName) !== GJC_TMUX_PROFILE_VALUE) {
|
|
2575
|
+
if (adoptUnmanagedSession && readGjcTmuxProfileValue(tmuxCommand, sessionName) !== GJC_TMUX_PROFILE_VALUE) {
|
|
2521
2576
|
// Adopt any real tmux leader as a GJC team leader — including a session
|
|
2522
2577
|
// the user created outside `gjc --tmux` — by writing GJC's @gjc-profile
|
|
2523
2578
|
// ownership tag and reading it back. A provider that round-trips tmux
|
|
@@ -2540,6 +2595,20 @@ function readCurrentTmuxLeaderContext(tmuxCommand: string, env: NodeJS.ProcessEn
|
|
|
2540
2595
|
target: `${sessionName}:${windowIndex}`,
|
|
2541
2596
|
};
|
|
2542
2597
|
}
|
|
2598
|
+
/**
|
|
2599
|
+
* Check whether the current process can launch a team without changing tmux state.
|
|
2600
|
+
* Unlike the launch path, this never adopts or tags an unmanaged tmux session.
|
|
2601
|
+
*/
|
|
2602
|
+
export function probeGjcTeamAvailability(
|
|
2603
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
2604
|
+
): { available: true } | { available: false; reason: string } {
|
|
2605
|
+
try {
|
|
2606
|
+
readCurrentTmuxLeaderContext(resolveGjcTmuxCommand(env), env, false);
|
|
2607
|
+
return { available: true };
|
|
2608
|
+
} catch (error) {
|
|
2609
|
+
return { available: false, reason: error instanceof Error ? error.message : String(error) };
|
|
2610
|
+
}
|
|
2611
|
+
}
|
|
2543
2612
|
function isBunVirtualPath(candidate: string | undefined): boolean {
|
|
2544
2613
|
const normalized = candidate?.trim().replace(/\\/g, "/").toLowerCase();
|
|
2545
2614
|
return (
|
|
@@ -2718,7 +2787,7 @@ async function startTmuxSession(
|
|
|
2718
2787
|
const splitDirection: string = worker.index === 1 ? "-h" : "-v";
|
|
2719
2788
|
const splitTarget: string =
|
|
2720
2789
|
worker.index === 1 ? config.tmux_target : (rightStackRootPaneId ?? config.tmux_target);
|
|
2721
|
-
const workerCommand = buildWorkerCommand(config, worker);
|
|
2790
|
+
const workerCommand = buildWorkerCommand(config, worker, process.platform, undefined, env);
|
|
2722
2791
|
const workerCwd = worker.worktree_path ?? config.leader.cwd;
|
|
2723
2792
|
const useSendKeysFallback = shouldDispatchWorkerWithSendKeys(config.tmux_command);
|
|
2724
2793
|
const splitArgs = [
|
|
@@ -4247,11 +4316,13 @@ function parseDurationEnv(env: NodeJS.ProcessEnv, name: string, fallbackMs: numb
|
|
|
4247
4316
|
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallbackMs;
|
|
4248
4317
|
}
|
|
4249
4318
|
|
|
4250
|
-
|
|
4319
|
+
/** Positive stale windows are clamped so a worker can publish strictly before expiry. */
|
|
4320
|
+
export function parseHeartbeatStaleMs(env: NodeJS.ProcessEnv): number {
|
|
4251
4321
|
const raw = env.GJC_TEAM_HEARTBEAT_STALE_MS?.trim();
|
|
4252
4322
|
if (!raw) return 120_000;
|
|
4253
4323
|
const parsed = Number(raw);
|
|
4254
|
-
|
|
4324
|
+
if (!Number.isFinite(parsed)) return 120_000;
|
|
4325
|
+
return parsed > 0 ? Math.max(3, parsed) : parsed;
|
|
4255
4326
|
}
|
|
4256
4327
|
async function writeLifecycleNudge(
|
|
4257
4328
|
dir: string,
|
|
@@ -4751,6 +4822,41 @@ export async function updateGjcWorkerHeartbeat(
|
|
|
4751
4822
|
updateWorkerHeartbeat(workerRuntime, teamName, worker, heartbeat, cwd, env),
|
|
4752
4823
|
);
|
|
4753
4824
|
}
|
|
4825
|
+
/**
|
|
4826
|
+
* Refresh liveness without discarding metadata written by other heartbeat
|
|
4827
|
+
* producers. The read/merge/write sequence shares the team mutation fence with
|
|
4828
|
+
* CLI heartbeat updates, so a runtime tick cannot overwrite a newer turn count.
|
|
4829
|
+
*/
|
|
4830
|
+
export async function refreshGjcWorkerHeartbeat(
|
|
4831
|
+
teamName: string,
|
|
4832
|
+
worker: string,
|
|
4833
|
+
pid: number,
|
|
4834
|
+
cwd = process.cwd(),
|
|
4835
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
4836
|
+
): Promise<WorkerHeartbeatFile> {
|
|
4837
|
+
const dir = await findTeamDir(teamName, cwd, env);
|
|
4838
|
+
return withGjcTeamMutationFence(dir, async () => {
|
|
4839
|
+
const current = await readWorkerHeartbeat(workerRuntime, teamName, worker, cwd, env);
|
|
4840
|
+
const processStartTime = await readLinuxProcessStartTime(pid);
|
|
4841
|
+
const turnCount = current?.turn_count;
|
|
4842
|
+
return updateWorkerHeartbeat(
|
|
4843
|
+
workerRuntime,
|
|
4844
|
+
teamName,
|
|
4845
|
+
worker,
|
|
4846
|
+
{
|
|
4847
|
+
...current,
|
|
4848
|
+
pid,
|
|
4849
|
+
last_turn_at: now(),
|
|
4850
|
+
turn_count: Number.isSafeInteger(turnCount) && (turnCount ?? -1) >= 0 ? (turnCount ?? 0) : 0,
|
|
4851
|
+
alive: true,
|
|
4852
|
+
// Never carry another process's incarnation across a pid change.
|
|
4853
|
+
process_start_time: processStartTime ?? (current?.pid === pid ? current.process_start_time : undefined),
|
|
4854
|
+
},
|
|
4855
|
+
cwd,
|
|
4856
|
+
env,
|
|
4857
|
+
);
|
|
4858
|
+
});
|
|
4859
|
+
}
|
|
4754
4860
|
export async function writeGjcWorkerInbox(
|
|
4755
4861
|
teamName: string,
|
|
4756
4862
|
worker: string,
|
|
@@ -4896,12 +5002,13 @@ export async function executeGjcTeamApiOperation(
|
|
|
4896
5002
|
cwd = process.cwd(),
|
|
4897
5003
|
env: NodeJS.ProcessEnv = process.env,
|
|
4898
5004
|
): Promise<unknown> {
|
|
5005
|
+
const resolvedOperation = resolveGjcTeamApiOperation(operation);
|
|
4899
5006
|
const teamName = String(input.team_name ?? input.teamName ?? "").trim();
|
|
4900
5007
|
if (!teamName) throw new Error("missing_team_name");
|
|
4901
5008
|
const workerInput = input.worker ?? input.worker_id ?? input.workerId;
|
|
4902
5009
|
const worker = String(workerInput ?? "worker-1");
|
|
4903
5010
|
const explicitWorker = workerInput == null ? undefined : String(workerInput);
|
|
4904
|
-
switch (
|
|
5011
|
+
switch (resolvedOperation) {
|
|
4905
5012
|
case "list-tasks":
|
|
4906
5013
|
return { tasks: await listGjcTeamTasks(teamName, cwd, env) };
|
|
4907
5014
|
case "read-task":
|
|
@@ -5247,7 +5354,8 @@ export async function executeGjcTeamApiOperation(
|
|
|
5247
5354
|
case "read-shutdown-ack":
|
|
5248
5355
|
return readGjcShutdownAck(teamName, worker, cwd, env);
|
|
5249
5356
|
default:
|
|
5250
|
-
|
|
5357
|
+
resolvedOperation satisfies never;
|
|
5358
|
+
throw new UnknownGjcTeamApiOperationError(operation, []);
|
|
5251
5359
|
}
|
|
5252
5360
|
}
|
|
5253
5361
|
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime-owned `gjc team` worker heartbeat.
|
|
3
|
+
*
|
|
4
|
+
* Worker liveness used to be published only when the model remembered to call
|
|
5
|
+
* `gjc team api update-worker-heartbeat` between turns. A worker inside a
|
|
6
|
+
* single tool call longer than `GJC_TEAM_HEARTBEAT_STALE_MS` (default 120s) was
|
|
7
|
+
* therefore reported as stale and had its task claim requeued mid-flight — even
|
|
8
|
+
* though the claim lease it invalidated is 30 minutes long. This module makes
|
|
9
|
+
* the worker process itself publish liveness while a turn is in flight, so the
|
|
10
|
+
* heartbeat records what the process is doing instead of what the model
|
|
11
|
+
* remembered to report.
|
|
12
|
+
*
|
|
13
|
+
* The published record carries the writer's real pid, so leader-side diagnostics
|
|
14
|
+
* name the process that is actually working rather than a placeholder.
|
|
15
|
+
*/
|
|
16
|
+
import { logger } from "@gajae-code/utils";
|
|
17
|
+
import { parseHeartbeatStaleMs, refreshGjcWorkerHeartbeat, type WorkerHeartbeatFile } from "./team-runtime";
|
|
18
|
+
|
|
19
|
+
/** Publish several times per stale window so one missed tick cannot cross it. */
|
|
20
|
+
const HEARTBEAT_INTERVAL_DIVISOR = 3;
|
|
21
|
+
const MIN_HEARTBEAT_INTERVAL_MS = 1;
|
|
22
|
+
const MAX_HEARTBEAT_INTERVAL_MS = 30_000;
|
|
23
|
+
|
|
24
|
+
export interface GjcTeamWorkerIdentity {
|
|
25
|
+
teamName: string;
|
|
26
|
+
workerId: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Resolves the team identity injected into a worker pane by `gjc team` startup. */
|
|
30
|
+
export function resolveGjcTeamWorkerIdentity(env: NodeJS.ProcessEnv = process.env): GjcTeamWorkerIdentity | undefined {
|
|
31
|
+
const teamName = env.GJC_TEAM_NAME?.trim();
|
|
32
|
+
const workerId = env.GJC_TEAM_WORKER_ID?.trim() || env.GJC_TEAM_INTERNAL_WORKER?.split("/").pop()?.trim();
|
|
33
|
+
return teamName && workerId ? { teamName, workerId } : undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Refresh cadence derived from the leader's stale window; `0` disables publishing. */
|
|
37
|
+
export function resolveGjcTeamWorkerHeartbeatIntervalMs(env: NodeJS.ProcessEnv = process.env): number {
|
|
38
|
+
const staleMs = parseHeartbeatStaleMs(env);
|
|
39
|
+
if (staleMs <= 0) return 0;
|
|
40
|
+
return Math.min(
|
|
41
|
+
MAX_HEARTBEAT_INTERVAL_MS,
|
|
42
|
+
Math.max(MIN_HEARTBEAT_INTERVAL_MS, Math.floor(staleMs / HEARTBEAT_INTERVAL_DIVISOR)),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Publish one runtime-owned heartbeat for the worker this process is running as.
|
|
48
|
+
* Returns `undefined` when the process is not a team worker.
|
|
49
|
+
*
|
|
50
|
+
* `turn_count` and process-incarnation metadata are merged under the same
|
|
51
|
+
* mutation fence used by CLI heartbeat updates.
|
|
52
|
+
*/
|
|
53
|
+
export async function writeGjcTeamWorkerRuntimeHeartbeat(
|
|
54
|
+
cwd: string = process.cwd(),
|
|
55
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
56
|
+
): Promise<WorkerHeartbeatFile | undefined> {
|
|
57
|
+
const identity = resolveGjcTeamWorkerIdentity(env);
|
|
58
|
+
if (!identity) return undefined;
|
|
59
|
+
return refreshGjcWorkerHeartbeat(identity.teamName, identity.workerId, process.pid, cwd, env);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface GjcTeamWorkerHeartbeatReporterOptions {
|
|
63
|
+
write: () => Promise<void>;
|
|
64
|
+
intervalMs: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Publishes a worker heartbeat while a turn is in flight.
|
|
69
|
+
*
|
|
70
|
+
* Writes never overlap; a slow or failing write is logged and skipped rather
|
|
71
|
+
* than queued, because only the most recent record matters. The timer is
|
|
72
|
+
* unreferenced so it can never hold the process open.
|
|
73
|
+
*/
|
|
74
|
+
export class GjcTeamWorkerHeartbeatReporter {
|
|
75
|
+
readonly #write: () => Promise<void>;
|
|
76
|
+
readonly #intervalMs: number;
|
|
77
|
+
#timer: NodeJS.Timeout | undefined;
|
|
78
|
+
#inFlight: Promise<void> | undefined;
|
|
79
|
+
#disposed = false;
|
|
80
|
+
|
|
81
|
+
constructor(options: GjcTeamWorkerHeartbeatReporterOptions) {
|
|
82
|
+
this.#write = options.write;
|
|
83
|
+
this.#intervalMs = options.intervalMs;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Reporter for the current process, or `undefined` when it is not a team worker. */
|
|
87
|
+
static forProcess(
|
|
88
|
+
cwd: () => string,
|
|
89
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
90
|
+
): GjcTeamWorkerHeartbeatReporter | undefined {
|
|
91
|
+
if (!resolveGjcTeamWorkerIdentity(env)) return undefined;
|
|
92
|
+
const intervalMs = resolveGjcTeamWorkerHeartbeatIntervalMs(env);
|
|
93
|
+
if (intervalMs <= 0) return undefined;
|
|
94
|
+
return new GjcTeamWorkerHeartbeatReporter({
|
|
95
|
+
intervalMs,
|
|
96
|
+
write: async () => {
|
|
97
|
+
await writeGjcTeamWorkerRuntimeHeartbeat(cwd(), env);
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
get isRunning(): boolean {
|
|
103
|
+
return this.#timer !== undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Publish immediately, then keep publishing until {@link stop} or {@link dispose}. */
|
|
107
|
+
start(): void {
|
|
108
|
+
if (this.#disposed || this.#timer) return;
|
|
109
|
+
this.#publish();
|
|
110
|
+
this.#timer = setInterval(() => this.#publish(), this.#intervalMs);
|
|
111
|
+
this.#timer.unref?.();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Stop periodic publishing after one final record, so the idle stamp is fresh. */
|
|
115
|
+
stop(): void {
|
|
116
|
+
if (!this.#timer) return;
|
|
117
|
+
clearInterval(this.#timer);
|
|
118
|
+
this.#timer = undefined;
|
|
119
|
+
this.#publish();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Await the write in flight, if any. */
|
|
123
|
+
async flush(): Promise<void> {
|
|
124
|
+
while (this.#inFlight) {
|
|
125
|
+
await this.#inFlight;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Permanently stop publishing. Idempotent. */
|
|
130
|
+
dispose(): void {
|
|
131
|
+
this.#disposed = true;
|
|
132
|
+
if (this.#timer) {
|
|
133
|
+
clearInterval(this.#timer);
|
|
134
|
+
this.#timer = undefined;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
#publish(): void {
|
|
139
|
+
if (this.#disposed || this.#inFlight) return;
|
|
140
|
+
const run = this.#write()
|
|
141
|
+
.catch((error: unknown) => {
|
|
142
|
+
logger.warn("GJC team worker heartbeat publish failed", { error: String(error) });
|
|
143
|
+
})
|
|
144
|
+
.finally(() => {
|
|
145
|
+
if (this.#inFlight === run) this.#inFlight = undefined;
|
|
146
|
+
});
|
|
147
|
+
this.#inFlight = run;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -110,6 +110,16 @@ export interface TmuxServerProof {
|
|
|
110
110
|
startTime?: string;
|
|
111
111
|
cgroup?: CgroupInfo;
|
|
112
112
|
sessionNames?: string[];
|
|
113
|
+
/**
|
|
114
|
+
* Whether `pid` is kernel-proved evidence about the live tmux server.
|
|
115
|
+
*
|
|
116
|
+
* Only Linux can prove a server PID (`/proc` + cgroup classification), so
|
|
117
|
+
* probes on other platforms may report a placeholder PID with this flag set
|
|
118
|
+
* to `false`. Guards must not build a `#{pid}` predicate from an unproven
|
|
119
|
+
* PID: no live tmux server can ever satisfy it. Omitted means proven, which
|
|
120
|
+
* keeps every probe that reads a real `#{pid}` unchanged.
|
|
121
|
+
*/
|
|
122
|
+
pidProven?: boolean;
|
|
113
123
|
}
|
|
114
124
|
|
|
115
125
|
export interface PlanRequest {
|
|
@@ -407,6 +407,7 @@ export function createGjcTmuxSession(
|
|
|
407
407
|
pid: 1,
|
|
408
408
|
startTime: "not-applicable",
|
|
409
409
|
cgroup: { classification: "not_applicable" },
|
|
410
|
+
pidProven: false,
|
|
410
411
|
};
|
|
411
412
|
}
|
|
412
413
|
const result = Bun.spawnSync([tmuxCommand, "display-message", "-p", "#{pid}"], {
|
|
@@ -534,6 +535,7 @@ export function createGjcTmuxSession(
|
|
|
534
535
|
env,
|
|
535
536
|
server.pid,
|
|
536
537
|
server.startTime,
|
|
538
|
+
server.pidProven,
|
|
537
539
|
);
|
|
538
540
|
},
|
|
539
541
|
});
|
|
@@ -551,7 +553,7 @@ export function createGjcTmuxSession(
|
|
|
551
553
|
tagCreatedTmuxSession(
|
|
552
554
|
nativeSessionId,
|
|
553
555
|
sessionName,
|
|
554
|
-
outcome.server_pid,
|
|
556
|
+
{ pid: outcome.server_pid, pidProven: server.pidProven },
|
|
555
557
|
env,
|
|
556
558
|
{
|
|
557
559
|
sessionId,
|
|
@@ -571,6 +573,7 @@ export function createGjcTmuxSession(
|
|
|
571
573
|
env,
|
|
572
574
|
outcome.server_pid,
|
|
573
575
|
outcome.server_start_time,
|
|
576
|
+
server.pidProven,
|
|
574
577
|
);
|
|
575
578
|
} catch (cleanupError) {
|
|
576
579
|
throw new AggregateError([tagError, cleanupError], "gjc_tmux_profile_tag_failed_cleanup_failed");
|
|
@@ -597,6 +600,7 @@ export function createGjcTmuxSession(
|
|
|
597
600
|
env,
|
|
598
601
|
outcome.server_pid,
|
|
599
602
|
outcome.server_start_time,
|
|
603
|
+
finalServer.pidProven,
|
|
600
604
|
);
|
|
601
605
|
} catch (cleanupError) {
|
|
602
606
|
throw new AggregateError(
|
|
@@ -619,6 +623,7 @@ export function createGjcTmuxSession(
|
|
|
619
623
|
env,
|
|
620
624
|
outcome.server_pid,
|
|
621
625
|
outcome.server_start_time,
|
|
626
|
+
server.pidProven,
|
|
622
627
|
);
|
|
623
628
|
} catch (cleanupError) {
|
|
624
629
|
throw new AggregateError(
|
|
@@ -645,8 +650,18 @@ function tmuxCommandArgument(value: string): string {
|
|
|
645
650
|
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("$", "\\$").replaceAll("`", "\\`")}"`;
|
|
646
651
|
}
|
|
647
652
|
|
|
653
|
+
/**
|
|
654
|
+
* Build the guard predicate for an exact tmux session mutation.
|
|
655
|
+
*
|
|
656
|
+
* The `#{pid}` clause is emitted only when the server proof actually proved a
|
|
657
|
+
* PID. Non-Linux probes report a placeholder PID (see {@link TmuxServerProof}),
|
|
658
|
+
* and pinning `#{pid}` to a placeholder produces a predicate no live tmux
|
|
659
|
+
* server can satisfy, which refuses every mutation on those platforms. The
|
|
660
|
+
* remaining clauses still pin the exact session id, session name and owner
|
|
661
|
+
* generation.
|
|
662
|
+
*/
|
|
648
663
|
function guardedTmuxSessionPredicate(
|
|
649
|
-
|
|
664
|
+
expectedServer: { pid: number; pidProven?: boolean },
|
|
650
665
|
nativeSessionId: string,
|
|
651
666
|
sessionName: string,
|
|
652
667
|
expectedOwnerGeneration?: string,
|
|
@@ -654,13 +669,14 @@ function guardedTmuxSessionPredicate(
|
|
|
654
669
|
const ownerGenerationPredicate = expectedOwnerGeneration
|
|
655
670
|
? `#{==:#{${GJC_TMUX_OWNER_GENERATION_OPTION}},${expectedOwnerGeneration}}`
|
|
656
671
|
: "1";
|
|
657
|
-
|
|
672
|
+
const serverPidPredicate = expectedServer.pidProven === false ? "1" : `#{==:#{pid},${expectedServer.pid}}`;
|
|
673
|
+
return `#{&&:${serverPidPredicate},#{&&:#{==:#{session_id},${nativeSessionId}},#{&&:#{==:#{session_name},${sessionName}},${ownerGenerationPredicate}}}}`;
|
|
658
674
|
}
|
|
659
675
|
|
|
660
676
|
function runGuardedTmuxSessionCommand(
|
|
661
677
|
nativeSessionId: string,
|
|
662
678
|
sessionName: string,
|
|
663
|
-
|
|
679
|
+
expectedServer: { pid: number; pidProven?: boolean },
|
|
664
680
|
env: NodeJS.ProcessEnv,
|
|
665
681
|
thenCommand: string,
|
|
666
682
|
expectedOwnerGeneration?: string,
|
|
@@ -671,7 +687,7 @@ function runGuardedTmuxSessionCommand(
|
|
|
671
687
|
"-t",
|
|
672
688
|
normalizeExactTmuxTarget(nativeSessionId, env, "session"),
|
|
673
689
|
"-F",
|
|
674
|
-
guardedTmuxSessionPredicate(
|
|
690
|
+
guardedTmuxSessionPredicate(expectedServer, nativeSessionId, sessionName, expectedOwnerGeneration),
|
|
675
691
|
`${thenCommand} ; display-message -p __gjc_tmux_guarded_mutation_ok__`,
|
|
676
692
|
"display-message -p __gjc_tmux_guarded_mutation_refused__",
|
|
677
693
|
],
|
|
@@ -683,7 +699,7 @@ function runGuardedTmuxSessionCommand(
|
|
|
683
699
|
function tagCreatedTmuxSession(
|
|
684
700
|
nativeSessionId: string,
|
|
685
701
|
sessionName: string,
|
|
686
|
-
|
|
702
|
+
expectedServer: { pid: number; pidProven?: boolean },
|
|
687
703
|
env: NodeJS.ProcessEnv,
|
|
688
704
|
metadata: {
|
|
689
705
|
branch?: string | null;
|
|
@@ -701,7 +717,7 @@ function tagCreatedTmuxSession(
|
|
|
701
717
|
const commands = buildGjcTmuxProfileCommands(target, env, metadata, { tmuxCommand })
|
|
702
718
|
.map(command => command.args.map(tmuxCommandArgument).join(" "))
|
|
703
719
|
.join(" ; ");
|
|
704
|
-
runGuardedTmuxSessionCommand(nativeSessionId, sessionName,
|
|
720
|
+
runGuardedTmuxSessionCommand(nativeSessionId, sessionName, expectedServer, env, commands);
|
|
705
721
|
}
|
|
706
722
|
|
|
707
723
|
function cleanupExactCreatedTmuxSession(
|
|
@@ -711,6 +727,7 @@ function cleanupExactCreatedTmuxSession(
|
|
|
711
727
|
env: NodeJS.ProcessEnv,
|
|
712
728
|
expectedPid: number,
|
|
713
729
|
expectedStartTime: string,
|
|
730
|
+
expectedPidProven?: boolean,
|
|
714
731
|
): void {
|
|
715
732
|
const server = requireSafeTmuxServerForMutation(tmuxCommand, env);
|
|
716
733
|
if (server.pid !== expectedPid || server.startTime !== expectedStartTime)
|
|
@@ -718,7 +735,7 @@ function cleanupExactCreatedTmuxSession(
|
|
|
718
735
|
runGuardedTmuxSessionCommand(
|
|
719
736
|
nativeSessionId,
|
|
720
737
|
sessionName,
|
|
721
|
-
expectedPid,
|
|
738
|
+
{ pid: expectedPid, pidProven: expectedPidProven ?? server.pidProven },
|
|
722
739
|
env,
|
|
723
740
|
`kill-session -t ${tmuxCommandArgument(normalizeExactTmuxTarget(nativeSessionId, env, "session"))}`,
|
|
724
741
|
);
|
|
@@ -727,7 +744,7 @@ function cleanupExactCreatedTmuxSession(
|
|
|
727
744
|
function requireSafeTmuxServerForMutation(
|
|
728
745
|
tmuxCommand: string,
|
|
729
746
|
env: NodeJS.ProcessEnv,
|
|
730
|
-
): { pid: number; startTime: string } {
|
|
747
|
+
): { pid: number; startTime: string; pidProven?: boolean } {
|
|
731
748
|
if (mutationServerProofTestDependency) {
|
|
732
749
|
const proof = mutationServerProofTestDependency(tmuxCommand, env);
|
|
733
750
|
if (
|
|
@@ -739,7 +756,7 @@ function requireSafeTmuxServerForMutation(
|
|
|
739
756
|
return proof as { pid: number; startTime: string };
|
|
740
757
|
return { pid: 1, startTime: "test" };
|
|
741
758
|
}
|
|
742
|
-
if (process.platform !== "linux") return { pid: 1, startTime: "not-applicable" };
|
|
759
|
+
if (process.platform !== "linux") return { pid: 1, startTime: "not-applicable", pidProven: false };
|
|
743
760
|
const result = Bun.spawnSync([tmuxCommand, "display-message", "-p", "#{pid}"], {
|
|
744
761
|
stdout: "pipe",
|
|
745
762
|
stderr: "pipe",
|
|
@@ -953,7 +970,7 @@ export function removeGjcTmuxSession(
|
|
|
953
970
|
runGuardedTmuxSessionCommand(
|
|
954
971
|
nativeSessionId,
|
|
955
972
|
session.name,
|
|
956
|
-
finalServer
|
|
973
|
+
finalServer,
|
|
957
974
|
env,
|
|
958
975
|
`kill-session -t '${nativeSessionId}'`,
|
|
959
976
|
expectedIdentity?.ownerGeneration,
|
|
@@ -962,6 +979,13 @@ export function removeGjcTmuxSession(
|
|
|
962
979
|
}
|
|
963
980
|
|
|
964
981
|
async function readProcessStartTime(pid: number): Promise<string | null> {
|
|
982
|
+
// `/proc/<pid>/stat` only exists on Linux. Everywhere else the natives
|
|
983
|
+
// process reference carries the same kernel-derived start identity (macOS
|
|
984
|
+
// reports `darwin:<start_tvsec>:<start_tvusec>` from the BSD proc info), and
|
|
985
|
+
// callers only ever compare these values for equality against another value
|
|
986
|
+
// produced here. Returning null off Linux made every owner-identity proof
|
|
987
|
+
// unverifiable, so no session could be closed there.
|
|
988
|
+
if (process.platform !== "linux") return Process.fromPid(pid)?.incarnation ?? null;
|
|
965
989
|
return readLinuxProcStartTime(pid);
|
|
966
990
|
}
|
|
967
991
|
|
|
@@ -973,6 +997,27 @@ function exactManagedOwnerSupervisor(supervisorPid: number, supervisorStartTime:
|
|
|
973
997
|
return supervisor;
|
|
974
998
|
}
|
|
975
999
|
|
|
1000
|
+
/**
|
|
1001
|
+
* Deliver SIGTERM to exactly one already-proved owner PID.
|
|
1002
|
+
*
|
|
1003
|
+
* `Process.signalRoot` routes through the owned pidfd on Linux and the owned
|
|
1004
|
+
* process handle on Windows, so PID reuse cannot redirect the signal. macOS has
|
|
1005
|
+
* no equivalent kernel authority and the native binding deliberately fails
|
|
1006
|
+
* closed there, which would leave every macOS force-close unable to signal its
|
|
1007
|
+
* owner at all. On that platform the caller has already re-proved the PID's
|
|
1008
|
+
* start-time incarnation immediately before this call — the same evidence the
|
|
1009
|
+
* native macOS signal path re-validates — so deliver to that exact PID.
|
|
1010
|
+
*/
|
|
1011
|
+
function signalManagedOwnerTerm(supervisor: Process, pid: number): boolean {
|
|
1012
|
+
if (process.platform !== "darwin") return supervisor.signalRoot(15);
|
|
1013
|
+
try {
|
|
1014
|
+
process.kill(pid, "SIGTERM");
|
|
1015
|
+
return true;
|
|
1016
|
+
} catch {
|
|
1017
|
+
return false;
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
|
|
976
1021
|
async function readCurrentGeneration(stateDir: string, sessionId: string): Promise<string | null> {
|
|
977
1022
|
try {
|
|
978
1023
|
const value: unknown = JSON.parse(
|
|
@@ -1185,7 +1230,7 @@ export async function forceCloseGjcTmuxSession(
|
|
|
1185
1230
|
deps.signalTerm(pid);
|
|
1186
1231
|
} else {
|
|
1187
1232
|
const supervisor = exactManagedOwnerSupervisor(pid, identity.startTime);
|
|
1188
|
-
if (!supervisor
|
|
1233
|
+
if (!signalManagedOwnerTerm(supervisor, pid)) throw new Error("managed_owner_supervisor_signal_failed");
|
|
1189
1234
|
operatorVerdict = supervisor
|
|
1190
1235
|
.waitForExit({ timeoutMs: FORCE_CLOSE_VERDICT_TIMEOUT_MS - 500 })
|
|
1191
1236
|
.then(async exited => {
|
|
@@ -1227,7 +1272,7 @@ export async function forceCloseGjcTmuxSession(
|
|
|
1227
1272
|
runGuardedTmuxSessionCommand(
|
|
1228
1273
|
nativeSessionId,
|
|
1229
1274
|
session.name,
|
|
1230
|
-
initialServer
|
|
1275
|
+
initialServer,
|
|
1231
1276
|
env,
|
|
1232
1277
|
`kill-session -t '${nativeSessionId}'`,
|
|
1233
1278
|
identity.generation,
|