@gajae-code/coding-agent 0.5.0 → 0.5.2
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 +36 -0
- package/README.md +1 -1
- package/dist/types/async/job-manager.d.ts +26 -0
- package/dist/types/cli/args.d.ts +1 -0
- package/dist/types/cli/list-models.d.ts +6 -0
- package/dist/types/cli/setup-cli.d.ts +8 -1
- package/dist/types/commands/gc.d.ts +26 -0
- package/dist/types/commands/setup.d.ts +7 -0
- package/dist/types/config/file-lock-gc.d.ts +5 -0
- package/dist/types/config/file-lock.d.ts +29 -0
- package/dist/types/config/model-registry.d.ts +4 -0
- package/dist/types/config/models-config-schema.d.ts +5 -0
- package/dist/types/config/settings-schema.d.ts +62 -0
- package/dist/types/coordinator/contract.d.ts +1 -1
- package/dist/types/defaults/gjc/extensions/grok-build/index.d.ts +1 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/index.d.ts +1 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.d.ts +25 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/payload/sanitize.d.ts +27 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/billing.d.ts +8 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.d.ts +5 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.d.ts +10 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/usage.d.ts +2 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.d.ts +2 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/shared/errors.d.ts +38 -0
- package/dist/types/defaults/gjc-grok-cli.d.ts +5 -0
- package/dist/types/extensibility/extensions/index.d.ts +1 -0
- package/dist/types/extensibility/extensions/prefix-command-bridge.d.ts +35 -0
- package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +103 -0
- package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +2 -0
- package/dist/types/gjc-runtime/deep-interview-state.d.ts +112 -0
- package/dist/types/gjc-runtime/gc-render.d.ts +6 -0
- package/dist/types/gjc-runtime/gc-runtime.d.ts +134 -0
- package/dist/types/gjc-runtime/ledger-event-renderer.d.ts +68 -0
- package/dist/types/gjc-runtime/state-writer.d.ts +64 -2
- package/dist/types/gjc-runtime/team-gc.d.ts +7 -0
- package/dist/types/gjc-runtime/team-runtime.d.ts +5 -0
- package/dist/types/gjc-runtime/tmux-common.d.ts +11 -0
- package/dist/types/gjc-runtime/tmux-gc.d.ts +7 -0
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +13 -0
- package/dist/types/gjc-runtime/ultragoal-guard.d.ts +10 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +29 -0
- package/dist/types/harness-control-plane/gc-adapter.d.ts +3 -0
- package/dist/types/harness-control-plane/owner.d.ts +7 -0
- package/dist/types/harness-control-plane/storage.d.ts +20 -0
- package/dist/types/modes/components/hook-selector.d.ts +7 -1
- package/dist/types/modes/components/provider-onboarding-selector.d.ts +1 -1
- package/dist/types/modes/controllers/command-controller.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +1 -1
- package/dist/types/modes/rpc/rpc-mode.d.ts +72 -2
- package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +13 -0
- package/dist/types/modes/shared/agent-wire/session-registry.d.ts +25 -0
- package/dist/types/modes/shared/agent-wire/unattended-action-policy.d.ts +2 -0
- package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +10 -0
- package/dist/types/modes/theme/defaults/index.d.ts +302 -0
- package/dist/types/modes/theme/theme.d.ts +1 -0
- package/dist/types/modes/types.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +1 -1
- package/dist/types/session/blob-store.d.ts +39 -3
- package/dist/types/session/history-storage.d.ts +2 -2
- package/dist/types/session/session-manager.d.ts +10 -1
- package/dist/types/setup/credential-import.d.ts +79 -0
- package/dist/types/skill-state/workflow-hud.d.ts +14 -0
- package/dist/types/task/executor.d.ts +1 -0
- package/dist/types/task/render.d.ts +1 -1
- package/dist/types/tools/ask.d.ts +15 -1
- package/dist/types/tools/subagent-render.d.ts +7 -1
- package/dist/types/tools/subagent.d.ts +27 -0
- package/dist/types/tools/ultragoal-ask-guard.d.ts +5 -0
- package/dist/types/web/search/index.d.ts +4 -4
- package/dist/types/web/search/provider.d.ts +16 -20
- package/dist/types/web/search/providers/base.d.ts +2 -1
- package/dist/types/web/search/providers/openai-compatible.d.ts +9 -0
- package/dist/types/web/search/types.d.ts +14 -2
- package/package.json +7 -7
- package/scripts/build-binary.ts +7 -0
- package/src/async/job-manager.ts +52 -0
- package/src/cli/args.ts +5 -0
- package/src/cli/auth-broker-cli.ts +1 -0
- package/src/cli/fast-help.ts +2 -0
- package/src/cli/list-models.ts +13 -1
- package/src/cli/setup-cli.ts +138 -3
- package/src/cli.ts +1 -0
- package/src/commands/gc.ts +22 -0
- package/src/commands/harness.ts +7 -3
- package/src/commands/setup.ts +5 -1
- package/src/commands/ultragoal.ts +3 -1
- package/src/config/file-lock-gc.ts +193 -0
- package/src/config/file-lock.ts +66 -10
- package/src/config/model-profile-activation.ts +15 -3
- package/src/config/model-profiles.ts +39 -30
- package/src/config/model-registry.ts +21 -1
- package/src/config/models-config-schema.ts +1 -0
- package/src/config/settings-schema.ts +62 -0
- package/src/coordinator/contract.ts +1 -0
- package/src/coordinator-mcp/server.ts +459 -3
- package/src/defaults/gjc/agent.models.grok-cli.yml +36 -0
- package/src/defaults/gjc/extensions/grok-build/index.ts +1 -0
- package/src/defaults/gjc/extensions/grok-build/package.json +7 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/biome.json +39 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/package.json +8 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/index.ts +1 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +155 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/payload/sanitize.ts +361 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/billing.ts +57 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +99 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.ts +50 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/usage.ts +56 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.ts +36 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/errors.ts +44 -0
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +131 -113
- package/src/defaults/gjc/skills/deep-interview/lateral-review-panel.md +49 -0
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +30 -8
- package/src/defaults/gjc-defaults.ts +7 -0
- package/src/defaults/gjc-grok-cli.ts +22 -0
- package/src/extensibility/extensions/index.ts +1 -0
- package/src/extensibility/extensions/prefix-command-bridge.ts +128 -0
- package/src/gjc-runtime/deep-interview-recorder.ts +457 -0
- package/src/gjc-runtime/deep-interview-runtime.ts +18 -26
- package/src/gjc-runtime/deep-interview-state.ts +324 -0
- package/src/gjc-runtime/gc-render.ts +70 -0
- package/src/gjc-runtime/gc-runtime.ts +403 -0
- package/src/gjc-runtime/launch-tmux.ts +3 -4
- package/src/gjc-runtime/ledger-event-renderer.ts +164 -0
- package/src/gjc-runtime/ralplan-runtime.ts +232 -19
- package/src/gjc-runtime/state-renderer.ts +12 -3
- package/src/gjc-runtime/state-runtime.ts +48 -30
- package/src/gjc-runtime/state-writer.ts +254 -7
- package/src/gjc-runtime/team-gc.ts +49 -0
- package/src/gjc-runtime/team-runtime.ts +179 -2
- package/src/gjc-runtime/tmux-common.ts +14 -0
- package/src/gjc-runtime/tmux-gc.ts +177 -0
- package/src/gjc-runtime/tmux-sessions.ts +49 -1
- package/src/gjc-runtime/ultragoal-guard.ts +155 -0
- package/src/gjc-runtime/ultragoal-runtime.ts +1239 -31
- package/src/gjc-runtime/workflow-manifest.generated.json +44 -0
- package/src/gjc-runtime/workflow-manifest.ts +12 -0
- package/src/harness-control-plane/gc-adapter.ts +184 -0
- package/src/harness-control-plane/owner.ts +14 -2
- package/src/harness-control-plane/rpc-adapter.ts +1 -1
- package/src/harness-control-plane/storage.ts +70 -0
- package/src/hooks/skill-state.ts +121 -2
- package/src/internal-urls/docs-index.generated.ts +22 -12
- package/src/lsp/defaults.json +1 -0
- package/src/main.ts +18 -3
- package/src/modes/acp/acp-agent.ts +4 -2
- package/src/modes/bridge/bridge-mode.ts +2 -1
- package/src/modes/components/history-search.ts +5 -2
- package/src/modes/components/hook-selector.ts +19 -0
- package/src/modes/components/model-selector.ts +51 -8
- package/src/modes/components/provider-onboarding-selector.ts +6 -1
- package/src/modes/components/status-line/segments.ts +1 -1
- package/src/modes/controllers/command-controller.ts +25 -6
- package/src/modes/controllers/extension-ui-controller.ts +3 -0
- package/src/modes/controllers/selector-controller.ts +81 -1
- package/src/modes/interactive-mode.ts +11 -1
- package/src/modes/rpc/rpc-mode.ts +266 -34
- package/src/modes/shared/agent-wire/command-dispatch.ts +281 -261
- package/src/modes/shared/agent-wire/deep-interview-gate.ts +30 -1
- package/src/modes/shared/agent-wire/host-tool-bridge.ts +3 -0
- package/src/modes/shared/agent-wire/session-registry.ts +109 -0
- package/src/modes/shared/agent-wire/unattended-action-policy.ts +24 -0
- package/src/modes/shared/agent-wire/unattended-run-controller.ts +23 -3
- package/src/modes/shared/agent-wire/unattended-session.ts +32 -2
- package/src/modes/theme/defaults/claude-code.json +100 -0
- package/src/modes/theme/defaults/codex.json +100 -0
- package/src/modes/theme/defaults/index.ts +6 -0
- package/src/modes/theme/defaults/opencode.json +102 -0
- package/src/modes/theme/theme.ts +2 -2
- package/src/modes/types.ts +1 -1
- package/src/prompts/agents/executor.md +5 -2
- package/src/sdk.ts +29 -4
- package/src/session/agent-session.ts +99 -19
- package/src/session/blob-store.ts +59 -3
- package/src/session/history-storage.ts +32 -11
- package/src/session/session-manager.ts +72 -20
- package/src/setup/credential-import.ts +429 -0
- package/src/setup/hermes/templates/operator-instructions.v1.md +7 -1
- package/src/skill-state/deep-interview-mutation-guard.ts +2 -1
- package/src/skill-state/workflow-hud.ts +106 -10
- package/src/slash-commands/builtin-registry.ts +3 -2
- package/src/task/executor.ts +16 -1
- package/src/task/render.ts +18 -7
- package/src/tools/ask.ts +59 -2
- package/src/tools/cron.ts +1 -1
- package/src/tools/job.ts +3 -2
- package/src/tools/monitor.ts +36 -1
- package/src/tools/subagent-render.ts +128 -29
- package/src/tools/subagent.ts +173 -9
- package/src/tools/ultragoal-ask-guard.ts +39 -0
- package/src/web/search/index.ts +25 -25
- package/src/web/search/provider.ts +178 -87
- package/src/web/search/providers/base.ts +2 -1
- package/src/web/search/providers/openai-compatible.ts +151 -0
- package/src/web/search/types.ts +47 -22
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import type { Stats } from "node:fs";
|
|
2
3
|
import * as fs from "node:fs/promises";
|
|
3
4
|
import * as path from "node:path";
|
|
5
|
+
import { type FileLockOptions, withFileLock } from "../config/file-lock";
|
|
4
6
|
import type { ActiveSubskillEntry, SkillActiveEntry, SkillActiveState } from "../skill-state/active-state";
|
|
5
7
|
import {
|
|
6
8
|
type AuditEntry,
|
|
@@ -80,6 +82,12 @@ export interface StateWriterOptions {
|
|
|
80
82
|
cwd?: string;
|
|
81
83
|
receipt?: StateWriterReceiptContext;
|
|
82
84
|
audit?: StateWriterAuditContext;
|
|
85
|
+
/**
|
|
86
|
+
* Cross-process lock tuning for read-modify-write paths that route through
|
|
87
|
+
* `withWorkflowStateLock` / `updateJsonAtomic`. Omit for the hardened
|
|
88
|
+
* `withFileLock` defaults.
|
|
89
|
+
*/
|
|
90
|
+
lock?: FileLockOptions;
|
|
83
91
|
}
|
|
84
92
|
|
|
85
93
|
export interface DeleteIfOwnedOptions extends StateWriterOptions {
|
|
@@ -113,7 +121,7 @@ export interface GenericHardPruneTarget {
|
|
|
113
121
|
export interface GenericHardPruneSelectorContext {
|
|
114
122
|
path: string;
|
|
115
123
|
category: WriterCategory | string;
|
|
116
|
-
stat:
|
|
124
|
+
stat: Stats;
|
|
117
125
|
readJson: () => Promise<unknown>;
|
|
118
126
|
}
|
|
119
127
|
|
|
@@ -388,6 +396,57 @@ export async function writeJsonAtomic(
|
|
|
388
396
|
return filePath;
|
|
389
397
|
}
|
|
390
398
|
|
|
399
|
+
async function readPersistedPhase(filePath: string): Promise<string | undefined> {
|
|
400
|
+
try {
|
|
401
|
+
const existing = await readJsonIfPresent(filePath);
|
|
402
|
+
if (!isPlainObject(existing)) return undefined;
|
|
403
|
+
// Only an *active* prior envelope is a transition source. A cleared / handed-off
|
|
404
|
+
// envelope (`active: false`, terminal phase such as `complete` / `handoff`) is outside
|
|
405
|
+
// active workflow progression, so reactivation from it (e.g. a fresh kickoff) must not
|
|
406
|
+
// be reported as an invalid transition.
|
|
407
|
+
if (existing.active !== true) return undefined;
|
|
408
|
+
const phase = existing.current_phase;
|
|
409
|
+
return typeof phase === "string" ? phase : undefined;
|
|
410
|
+
} catch {
|
|
411
|
+
// Best-effort diagnostic read: a corrupt/unreadable prior envelope simply yields no
|
|
412
|
+
// `from` phase, so the transition invariant degrades to a no-op rather than failing
|
|
413
|
+
// the sanctioned write it is observing.
|
|
414
|
+
return undefined;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function recordInvalidWorkflowTransition(args: {
|
|
419
|
+
filePath: string;
|
|
420
|
+
skill: CanonicalGjcWorkflowSkill;
|
|
421
|
+
fromPhase: string;
|
|
422
|
+
toPhase: string;
|
|
423
|
+
options?: StateWriterOptions;
|
|
424
|
+
}): Promise<void> {
|
|
425
|
+
const { filePath, skill, fromPhase, toPhase, options } = args;
|
|
426
|
+
// Audit-only diagnostic: a successful sanctioned write must NOT emit to stderr — callers
|
|
427
|
+
// may treat any stderr output as failure or parse stdout/stderr as machine output. The
|
|
428
|
+
// `invalid_transition_detected` audit entry is the durable, non-intrusive evidence that an
|
|
429
|
+
// internal write skipped a manifest edge.
|
|
430
|
+
const cwd = path.resolve(options?.audit?.cwd ?? options?.cwd ?? process.cwd());
|
|
431
|
+
try {
|
|
432
|
+
await appendAuditEntry(cwd, {
|
|
433
|
+
ts: new Date().toISOString(),
|
|
434
|
+
skill,
|
|
435
|
+
category: "state",
|
|
436
|
+
verb: "invalid_transition_detected",
|
|
437
|
+
owner: options?.audit?.owner ?? "gjc-runtime",
|
|
438
|
+
mutation_id: options?.audit?.mutationId ?? `${skill}:invalid-transition:${new Date().toISOString()}`,
|
|
439
|
+
from_phase: fromPhase,
|
|
440
|
+
to_phase: toPhase,
|
|
441
|
+
forced: false,
|
|
442
|
+
paths: [filePath],
|
|
443
|
+
});
|
|
444
|
+
} catch {
|
|
445
|
+
// Audit logging is best-effort diagnostics; never fail a sanctioned write because the
|
|
446
|
+
// audit append failed (e.g. cwd is not a writable project root).
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
391
450
|
export async function writeWorkflowEnvelopeAtomic(
|
|
392
451
|
targetPath: string,
|
|
393
452
|
value: unknown,
|
|
@@ -404,6 +463,50 @@ export async function writeWorkflowEnvelopeAtomic(
|
|
|
404
463
|
.join("; ")}`,
|
|
405
464
|
);
|
|
406
465
|
}
|
|
466
|
+
// #658: internal runtime writers (ralplan/ultragoal/deep-interview/team) persist
|
|
467
|
+
// envelopes directly, bypassing the `gjc state` CLI transition gate (`isValidTransition`,
|
|
468
|
+
// historically the sole call site in state-runtime.ts). Re-assert that gate on every
|
|
469
|
+
// sanctioned envelope write so internal writes cannot persist invalid state-machine phase
|
|
470
|
+
// transitions silently. Forced writes (`gjc state ... --force`, reconcile repairs) carry
|
|
471
|
+
// `audit.forced` and bypass, mirroring the CLI's `use --force to bypass`.
|
|
472
|
+
//
|
|
473
|
+
// The gate governs ACTIVE workflow progression only. Deactivation/teardown writes
|
|
474
|
+
// (`active: false`, e.g. `gjc state clear`, which persists the universal `complete`
|
|
475
|
+
// sentinel that is not a per-skill manifest state) leave the transition graph and are
|
|
476
|
+
// intentionally exempt.
|
|
477
|
+
if (options?.audit?.forced !== true && parsed.data.active === true) {
|
|
478
|
+
const toPhase = parsed.data.current_phase.trim();
|
|
479
|
+
if (toPhase) {
|
|
480
|
+
// Lazy import: workflow-manifest dereferences CANONICAL_GJC_WORKFLOW_SKILLS at
|
|
481
|
+
// module load, and active-state -> state-writer -> workflow-manifest -> active-state
|
|
482
|
+
// is a load-time cycle. Importing at call time (after init) avoids the TDZ.
|
|
483
|
+
const { isKnownWorkflowState, isValidTransition } = await import("./workflow-manifest");
|
|
484
|
+
const skill = parsed.data.skill;
|
|
485
|
+
// Structural invariant (hard): a `current_phase` absent from the skill's manifest is
|
|
486
|
+
// never a legitimate internal write, matching the CLI/reconcile unknown-phase gate.
|
|
487
|
+
if (!isKnownWorkflowState(skill, toPhase)) {
|
|
488
|
+
throw new Error(
|
|
489
|
+
`Refusing to write unknown ${skill} phase "${toPhase}" to ${filePath}: not a known ${skill} manifest state (forced writes bypass via audit.forced)`,
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
// Transition invariant (#658, diagnostic-only safety net): resolve the prior phase
|
|
493
|
+
// (caller-supplied `audit.fromPhase`, else the active persisted envelope on disk) and
|
|
494
|
+
// flag edges the manifest does not define. Intentionally NON-blocking and audit-only
|
|
495
|
+
// — the CLI path already hard-fails invalid edges before reaching here, and legitimate
|
|
496
|
+
// internal repairs / ralplan short-mode stage skips move between valid states without a
|
|
497
|
+
// direct manifest edge. It records an `invalid_transition_detected` audit entry (no
|
|
498
|
+
// stderr) so such transitions are non-silent without breaking those flows.
|
|
499
|
+
const fromPhase = (options?.audit?.fromPhase ?? (await readPersistedPhase(filePath)))?.trim();
|
|
500
|
+
if (
|
|
501
|
+
fromPhase &&
|
|
502
|
+
fromPhase !== toPhase &&
|
|
503
|
+
isKnownWorkflowState(skill, fromPhase) &&
|
|
504
|
+
!isValidTransition(skill, fromPhase, toPhase)
|
|
505
|
+
) {
|
|
506
|
+
await recordInvalidWorkflowTransition({ filePath, skill, fromPhase, toPhase, options });
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
407
510
|
await atomicWrite(filePath, jsonText(stamped));
|
|
408
511
|
await maybeAudit(filePath, options);
|
|
409
512
|
return filePath;
|
|
@@ -416,17 +519,55 @@ export async function writeTextAtomic(targetPath: string, text: string, options?
|
|
|
416
519
|
return filePath;
|
|
417
520
|
}
|
|
418
521
|
|
|
522
|
+
/**
|
|
523
|
+
* Serialize a read-modify-write (or any multi-step mutation) against concurrent
|
|
524
|
+
* writers of the same `.gjc/**` target. Uses the cross-process directory lock
|
|
525
|
+
* from `withFileLock`, keyed on the resolved file path, so separate CLI/agent
|
|
526
|
+
* processes (e.g. team-mode workers) cannot interleave one writer's read with
|
|
527
|
+
* another writer's write and silently drop the first mutation (issue #646).
|
|
528
|
+
*
|
|
529
|
+
* The lock is advisory: it only protects callers that route through it, so every
|
|
530
|
+
* read-modify-write of a given file MUST acquire this lock for the same resolved
|
|
531
|
+
* path. `atomicWrite`'s temp-file + rename crash-atomicity is preserved; this
|
|
532
|
+
* layers concurrency-atomicity on top without weakening it.
|
|
533
|
+
*/
|
|
534
|
+
export async function withWorkflowStateLock<T>(
|
|
535
|
+
targetPath: string,
|
|
536
|
+
fn: () => Promise<T>,
|
|
537
|
+
options?: StateWriterOptions,
|
|
538
|
+
): Promise<T> {
|
|
539
|
+
const filePath = resolveGjcTarget(targetPath, cwdForOptions(options));
|
|
540
|
+
return lockResolvedWorkflowTarget(filePath, fn, options?.lock);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
async function lockResolvedWorkflowTarget<T>(
|
|
544
|
+
filePath: string,
|
|
545
|
+
fn: () => Promise<T>,
|
|
546
|
+
lockOptions?: FileLockOptions,
|
|
547
|
+
): Promise<T> {
|
|
548
|
+
// `withFileLock` creates the lock dir next to the target with a non-recursive
|
|
549
|
+
// mkdir, so the parent directory must exist before the lock is acquired.
|
|
550
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
551
|
+
return withFileLock(filePath, fn, lockOptions);
|
|
552
|
+
}
|
|
553
|
+
|
|
419
554
|
export async function updateJsonAtomic<T = unknown>(
|
|
420
555
|
targetPath: string,
|
|
421
556
|
mutator: (current: T | undefined) => T | Promise<T>,
|
|
422
557
|
options?: StateWriterOptions,
|
|
423
558
|
): Promise<string> {
|
|
424
559
|
const filePath = resolveGjcTarget(targetPath, cwdForOptions(options));
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
560
|
+
return lockResolvedWorkflowTarget(
|
|
561
|
+
filePath,
|
|
562
|
+
async () => {
|
|
563
|
+
const current = (await readJsonIfPresent(filePath)) as T | undefined;
|
|
564
|
+
const next = await mutator(current);
|
|
565
|
+
await atomicWrite(filePath, jsonText(withWorkflowReceipt(next, buildReceipt(options))));
|
|
566
|
+
await maybeAudit(filePath, options);
|
|
567
|
+
return filePath;
|
|
568
|
+
},
|
|
569
|
+
options?.lock,
|
|
570
|
+
);
|
|
430
571
|
}
|
|
431
572
|
|
|
432
573
|
export async function appendJsonl(targetPath: string, entry: unknown, options?: StateWriterOptions): Promise<string> {
|
|
@@ -437,6 +578,112 @@ export async function appendJsonl(targetPath: string, entry: unknown, options?:
|
|
|
437
578
|
return filePath;
|
|
438
579
|
}
|
|
439
580
|
|
|
581
|
+
export interface AppendJsonlIdempotentOptions extends StateWriterOptions {
|
|
582
|
+
/**
|
|
583
|
+
* Identity key for an entry. Two entries that produce the same non-`undefined`
|
|
584
|
+
* key are duplicates, so only the first is appended. Return `undefined` to opt a
|
|
585
|
+
* candidate out of dedup (it is always appended). Use `key` for the common case
|
|
586
|
+
* where identity reduces to a single string.
|
|
587
|
+
*/
|
|
588
|
+
key?: (entry: unknown) => string | undefined;
|
|
589
|
+
/**
|
|
590
|
+
* Equivalence predicate: return `true` when `existing` already represents
|
|
591
|
+
* `candidate`, suppressing the append. Use when identity cannot be reduced to a
|
|
592
|
+
* single string key. When both `key` and `equals` are supplied, `equals` wins.
|
|
593
|
+
*/
|
|
594
|
+
equals?: (candidate: unknown, existing: unknown) => boolean;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
export interface AppendJsonlIdempotentResult {
|
|
598
|
+
path: string;
|
|
599
|
+
/** `true` when the entry was written; `false` when an equivalent entry already existed. */
|
|
600
|
+
appended: boolean;
|
|
601
|
+
/** The pre-existing entry that suppressed the append, when `appended` is `false`. */
|
|
602
|
+
duplicate?: unknown;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
async function readJsonlEntries(filePath: string): Promise<unknown[]> {
|
|
606
|
+
let raw: string;
|
|
607
|
+
try {
|
|
608
|
+
raw = await fs.readFile(filePath, "utf-8");
|
|
609
|
+
} catch (error) {
|
|
610
|
+
if (isErrno(error, "ENOENT")) return [];
|
|
611
|
+
throw error;
|
|
612
|
+
}
|
|
613
|
+
const entries: unknown[] = [];
|
|
614
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
615
|
+
const trimmed = line.trim();
|
|
616
|
+
if (!trimmed) continue;
|
|
617
|
+
try {
|
|
618
|
+
entries.push(JSON.parse(trimmed));
|
|
619
|
+
} catch {
|
|
620
|
+
// Best-effort: dedup compares parseable rows only. A corrupt line cannot
|
|
621
|
+
// be matched, so it never suppresses a new append.
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
return entries;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function findJsonlDuplicate(
|
|
628
|
+
existing: readonly unknown[],
|
|
629
|
+
candidate: unknown,
|
|
630
|
+
options: AppendJsonlIdempotentOptions,
|
|
631
|
+
): unknown | undefined {
|
|
632
|
+
if (options.equals) {
|
|
633
|
+
const equals = options.equals;
|
|
634
|
+
return existing.find(item => equals(candidate, item));
|
|
635
|
+
}
|
|
636
|
+
const key = options.key;
|
|
637
|
+
if (!key) return undefined;
|
|
638
|
+
const candidateKey = key(candidate);
|
|
639
|
+
if (candidateKey === undefined) return undefined;
|
|
640
|
+
return existing.find(item => key(item) === candidateKey);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Append `entry` to a JSONL file only when no equivalent entry already exists —
|
|
645
|
+
* the shared idempotent append primitive (issue #660).
|
|
646
|
+
*
|
|
647
|
+
* `appendJsonl` is a pure append with no dedup, so every recurring "duplicate
|
|
648
|
+
* ledger row" bug (#638, #643, #645) had to be patched with bespoke per-call-site
|
|
649
|
+
* guards. This primitive centralizes the read-check-append cycle: a caller
|
|
650
|
+
* declares identity once via `key` or `equals` instead of re-deriving the lookup
|
|
651
|
+
* at each site.
|
|
652
|
+
*
|
|
653
|
+
* The read-then-append is serialized through the same cross-process workflow lock
|
|
654
|
+
* as `updateJsonAtomic`, so two concurrent idempotent appends cannot both observe
|
|
655
|
+
* "no duplicate" and both write (the #646 TOCTOU that a plain `appendJsonl`
|
|
656
|
+
* preceded by a manual existence check is still exposed to).
|
|
657
|
+
*
|
|
658
|
+
* Scope note: this dedups the *append* only. Call sites whose idempotency must
|
|
659
|
+
* also skip a coupled mutation — e.g. the plan/state rewrite in #643/#645 — still
|
|
660
|
+
* need a whole-operation guard; this primitive is the ledger-level half of that.
|
|
661
|
+
*/
|
|
662
|
+
export async function appendJsonlIdempotent(
|
|
663
|
+
targetPath: string,
|
|
664
|
+
entry: unknown,
|
|
665
|
+
options: AppendJsonlIdempotentOptions,
|
|
666
|
+
): Promise<AppendJsonlIdempotentResult> {
|
|
667
|
+
if (!options.key && !options.equals) {
|
|
668
|
+
throw new Error("appendJsonlIdempotent requires a `key` or `equals` option to detect duplicates");
|
|
669
|
+
}
|
|
670
|
+
const filePath = resolveGjcTarget(targetPath, cwdForOptions(options));
|
|
671
|
+
return lockResolvedWorkflowTarget(
|
|
672
|
+
filePath,
|
|
673
|
+
async () => {
|
|
674
|
+
const existing = await readJsonlEntries(filePath);
|
|
675
|
+
const duplicate = findJsonlDuplicate(existing, entry, options);
|
|
676
|
+
if (duplicate !== undefined) {
|
|
677
|
+
return { path: filePath, appended: false, duplicate };
|
|
678
|
+
}
|
|
679
|
+
await fs.appendFile(filePath, `${JSON.stringify(entry)}\n`, "utf-8");
|
|
680
|
+
await maybeAudit(filePath, options);
|
|
681
|
+
return { path: filePath, appended: true };
|
|
682
|
+
},
|
|
683
|
+
options.lock,
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
|
|
440
687
|
export async function appendText(targetPath: string, text: string, options?: StateWriterOptions): Promise<string> {
|
|
441
688
|
const filePath = resolveGjcTarget(targetPath, cwdForOptions(options));
|
|
442
689
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
@@ -639,7 +886,7 @@ export async function hardPrune(
|
|
|
639
886
|
const removed: string[] = [];
|
|
640
887
|
for (const target of targets) {
|
|
641
888
|
const filePath = resolveGjcTarget(target.path, cwd);
|
|
642
|
-
let stat:
|
|
889
|
+
let stat: Stats;
|
|
643
890
|
try {
|
|
644
891
|
stat = await fs.stat(filePath);
|
|
645
892
|
} catch (error) {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GC adapter for team workers (`.gjc/state/team/<name>/workers/<id>/` heartbeat
|
|
3
|
+
* + lifecycle). Liveness-only: numeric PID status dominates lifecycle/heartbeat
|
|
4
|
+
* signals.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as path from "node:path";
|
|
8
|
+
import { listHarnessRootRegistriesForGc } from "../harness-control-plane/storage";
|
|
9
|
+
import type { GcCollectResult, GcContext, GcPruneOutcome, GcRecord, GcStoreAdapter } from "./gc-runtime";
|
|
10
|
+
import { listTeamWorkerGcRecords, pruneTeamWorkerGcRecord } from "./team-runtime";
|
|
11
|
+
|
|
12
|
+
function uniqueTeamRootsFromHarnessRoots(roots: string[]): string[] {
|
|
13
|
+
return [...new Set(roots.map(root => path.join(path.dirname(root), "team")))].sort();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const teamWorkersGcAdapter: GcStoreAdapter = {
|
|
17
|
+
store: "team_workers",
|
|
18
|
+
async collect(ctx: GcContext): Promise<GcCollectResult> {
|
|
19
|
+
const records: GcRecord[] = [];
|
|
20
|
+
const errors: GcCollectResult["errors"] = [];
|
|
21
|
+
const registries = await listHarnessRootRegistriesForGc(ctx.env);
|
|
22
|
+
for (const registry of registries) {
|
|
23
|
+
if (registry.error) errors.push({ store: "team_workers", scope: registry.file, message: registry.error });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const teamRoots = uniqueTeamRootsFromHarnessRoots(
|
|
27
|
+
registries.flatMap(registry => registry.roots.map(entry => entry.root)),
|
|
28
|
+
);
|
|
29
|
+
for (const teamRoot of teamRoots) {
|
|
30
|
+
try {
|
|
31
|
+
records.push(...(await listTeamWorkerGcRecords(teamRoot, ctx.probe)));
|
|
32
|
+
} catch (error) {
|
|
33
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
34
|
+
if (code === "ENOENT") continue;
|
|
35
|
+
errors.push({ store: "team_workers", scope: teamRoot, message: (error as Error).message });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return { records, errors };
|
|
40
|
+
},
|
|
41
|
+
async prune(record: GcRecord, ctx: GcContext): Promise<GcPruneOutcome> {
|
|
42
|
+
try {
|
|
43
|
+
const removed = await pruneTeamWorkerGcRecord(record, ctx.probe);
|
|
44
|
+
return removed ? { removed: true } : { removed: false, skipped: "worker_no_longer_dead" };
|
|
45
|
+
} catch (error) {
|
|
46
|
+
return { removed: false, error: (error as Error).message };
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
};
|
|
@@ -4,6 +4,7 @@ import * as path from "node:path";
|
|
|
4
4
|
import type { WorkflowHudSummary } from "../skill-state/active-state";
|
|
5
5
|
import { buildTeamHudSummary as buildWorkflowTeamHudSummary } from "../skill-state/workflow-hud";
|
|
6
6
|
import { WORKFLOW_STATE_VERSION } from "../skill-state/workflow-state-contract";
|
|
7
|
+
import type { GcPidProbe, GcRecord } from "./gc-runtime";
|
|
7
8
|
|
|
8
9
|
import { applyGjcTmuxProfile, GJC_TMUX_LAUNCHED_ENV } from "./launch-tmux";
|
|
9
10
|
import {
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
writeWorkflowEnvelopeAtomic,
|
|
19
20
|
} from "./state-writer";
|
|
20
21
|
import {
|
|
22
|
+
buildGjcTmuxExactOptionTarget,
|
|
21
23
|
buildGjcTmuxUntaggedSessionHint,
|
|
22
24
|
GJC_TMUX_PROFILE_OPTION,
|
|
23
25
|
GJC_TMUX_PROFILE_VALUE,
|
|
@@ -677,6 +679,174 @@ async function readJsonFile<T>(filePath: string): Promise<T | null> {
|
|
|
677
679
|
throw error;
|
|
678
680
|
}
|
|
679
681
|
}
|
|
682
|
+
function isPositivePid(value: unknown): value is number {
|
|
683
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function collectTeamGcWorkerPids(
|
|
687
|
+
heartbeat: WorkerHeartbeatFile | null,
|
|
688
|
+
lifecycle: GjcTeamWorkerLifecycle | null,
|
|
689
|
+
): number[] {
|
|
690
|
+
const pids: number[] = [];
|
|
691
|
+
if (isPositivePid(heartbeat?.pid)) pids.push(heartbeat.pid);
|
|
692
|
+
if (isPositivePid(lifecycle?.pid) && !pids.includes(lifecycle.pid)) pids.push(lifecycle.pid);
|
|
693
|
+
return pids;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
interface TeamGcPidClassification {
|
|
697
|
+
removable: boolean;
|
|
698
|
+
pidStatus: "dead" | "alive" | "eperm" | "unknown" | "none";
|
|
699
|
+
pid?: number;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Liveness-only, fail-closed: a worker is removable ONLY when it has at least
|
|
704
|
+
* one authoritative pid and EVERY candidate pid probes dead (ESRCH). Any alive,
|
|
705
|
+
* EPERM, or unknown candidate (heartbeat OR lifecycle) keeps the worker, so a
|
|
706
|
+
* dead heartbeat pid can never override a live lifecycle pid.
|
|
707
|
+
*/
|
|
708
|
+
function classifyTeamGcWorkerPids(pids: number[], probe: GcPidProbe): TeamGcPidClassification {
|
|
709
|
+
if (pids.length === 0) return { removable: false, pidStatus: "none" };
|
|
710
|
+
const statuses = pids.map(pid => ({ pid, status: gcProbeStatus(probe, pid) }));
|
|
711
|
+
const kept = statuses.find(entry => entry.status !== "dead");
|
|
712
|
+
if (kept) return { removable: false, pidStatus: kept.status, pid: kept.pid };
|
|
713
|
+
return { removable: true, pidStatus: "dead", pid: statuses[0]?.pid };
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function gcProbeStatus(probe: GcPidProbe, pid: number): "dead" | "alive" | "eperm" | "unknown" {
|
|
717
|
+
const result = probe(pid);
|
|
718
|
+
if (result.status === "dead") return "dead";
|
|
719
|
+
return result.reason ?? "unknown";
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function teamGcRecordDetail(heartbeat: WorkerHeartbeatFile | null, lifecycle: GjcTeamWorkerLifecycle | null): string {
|
|
723
|
+
return [
|
|
724
|
+
`heartbeat=${heartbeat ? "present" : "missing"}`,
|
|
725
|
+
...(heartbeat ? [`heartbeat_alive=${heartbeat.alive}`, `last_turn_at=${heartbeat.last_turn_at}`] : []),
|
|
726
|
+
`lifecycle=${lifecycle?.lifecycle_state ?? "missing"}`,
|
|
727
|
+
...(lifecycle?.pane_id ? [`pane_id=${lifecycle.pane_id}`] : []),
|
|
728
|
+
...(lifecycle?.stop_reason ? [`stop_reason=${lifecycle.stop_reason}`] : []),
|
|
729
|
+
].join(" ");
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/** @internal */
|
|
733
|
+
export async function listTeamWorkerGcRecords(teamRoot: string, probe: GcPidProbe): Promise<GcRecord[]> {
|
|
734
|
+
const teamEntries = await fs.readdir(teamRoot, { withFileTypes: true });
|
|
735
|
+
const records: GcRecord[] = [];
|
|
736
|
+
for (const teamEntry of teamEntries) {
|
|
737
|
+
if (!teamEntry.isDirectory()) continue;
|
|
738
|
+
const teamName = teamEntry.name;
|
|
739
|
+
const teamDirPath = path.join(teamRoot, teamName);
|
|
740
|
+
let workerEntries: import("node:fs").Dirent[];
|
|
741
|
+
try {
|
|
742
|
+
workerEntries = await fs.readdir(path.join(teamDirPath, "workers"), { withFileTypes: true });
|
|
743
|
+
} catch (error) {
|
|
744
|
+
if (isEnoent(error)) continue;
|
|
745
|
+
throw error;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
for (const workerEntry of workerEntries) {
|
|
749
|
+
if (!workerEntry.isDirectory()) continue;
|
|
750
|
+
const workerId = workerEntry.name;
|
|
751
|
+
const dir = path.join(teamDirPath, "workers", workerId);
|
|
752
|
+
let heartbeat: WorkerHeartbeatFile | null = null;
|
|
753
|
+
let lifecycle: GjcTeamWorkerLifecycle | null = null;
|
|
754
|
+
try {
|
|
755
|
+
heartbeat = await readJsonFile<WorkerHeartbeatFile>(path.join(dir, "heartbeat.json"));
|
|
756
|
+
lifecycle = await readJsonFile<GjcTeamWorkerLifecycle>(path.join(dir, "lifecycle.json"));
|
|
757
|
+
} catch (error) {
|
|
758
|
+
records.push({
|
|
759
|
+
store: "team_workers",
|
|
760
|
+
id: `${teamName}/${workerId}`,
|
|
761
|
+
root: teamRoot,
|
|
762
|
+
path: dir,
|
|
763
|
+
pid_status: "none",
|
|
764
|
+
status: "malformed",
|
|
765
|
+
stale: false,
|
|
766
|
+
removable: false,
|
|
767
|
+
action: "none",
|
|
768
|
+
reason: "worker_state_malformed_kept",
|
|
769
|
+
error: error instanceof Error ? error.message : String(error),
|
|
770
|
+
});
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
const pids = collectTeamGcWorkerPids(heartbeat, lifecycle);
|
|
774
|
+
const { removable, pidStatus, pid } = classifyTeamGcWorkerPids(pids, probe);
|
|
775
|
+
const terminalLifecycle = lifecycle?.lifecycle_state === "failed" || lifecycle?.lifecycle_state === "stopped";
|
|
776
|
+
const status = removable
|
|
777
|
+
? "dead"
|
|
778
|
+
: pidStatus === "none" && terminalLifecycle
|
|
779
|
+
? "terminal_lifecycle"
|
|
780
|
+
: pidStatus === "none"
|
|
781
|
+
? "no_pid"
|
|
782
|
+
: pidStatus;
|
|
783
|
+
records.push({
|
|
784
|
+
store: "team_workers",
|
|
785
|
+
id: `${teamName}/${workerId}`,
|
|
786
|
+
root: teamRoot,
|
|
787
|
+
path: dir,
|
|
788
|
+
pid,
|
|
789
|
+
pid_status: pidStatus,
|
|
790
|
+
status,
|
|
791
|
+
stale: removable,
|
|
792
|
+
removable,
|
|
793
|
+
action: "none",
|
|
794
|
+
reason: removable
|
|
795
|
+
? "worker_all_pids_dead"
|
|
796
|
+
: pidStatus === "none" && terminalLifecycle
|
|
797
|
+
? "terminal_lifecycle_without_pid_kept"
|
|
798
|
+
: pidStatus === "none"
|
|
799
|
+
? "worker_pid_missing_kept"
|
|
800
|
+
: `worker_pid_${pidStatus}_kept`,
|
|
801
|
+
detail: teamGcRecordDetail(heartbeat, lifecycle),
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
return records;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/** @internal */
|
|
809
|
+
export async function pruneTeamWorkerGcRecord(record: GcRecord, probe: GcPidProbe): Promise<boolean> {
|
|
810
|
+
if (!record.path || !record.id.includes("/")) return false;
|
|
811
|
+
const [teamName, workerId] = record.id.split("/", 2);
|
|
812
|
+
if (!teamName || !workerId) return false;
|
|
813
|
+
const teamDirPath = path.dirname(path.dirname(record.path));
|
|
814
|
+
const heartbeat = await readJsonFile<WorkerHeartbeatFile>(path.join(record.path, "heartbeat.json"));
|
|
815
|
+
const lifecycle = await readJsonFile<GjcTeamWorkerLifecycle>(path.join(record.path, "lifecycle.json"));
|
|
816
|
+
const pids = collectTeamGcWorkerPids(heartbeat, lifecycle);
|
|
817
|
+
if (!classifyTeamGcWorkerPids(pids, probe).removable) return false;
|
|
818
|
+
|
|
819
|
+
const claimDir = path.join(teamDirPath, "claims");
|
|
820
|
+
try {
|
|
821
|
+
for (const entry of await fs.readdir(claimDir, { withFileTypes: true })) {
|
|
822
|
+
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
|
823
|
+
const claimPath = path.join(claimDir, entry.name);
|
|
824
|
+
const claim = readClaimRecord(await readJsonFile<unknown>(claimPath));
|
|
825
|
+
if (claim?.owner !== workerId) continue;
|
|
826
|
+
await removeFileAudited(claimPath, stateWriterOptions(claimPath, "prune", "gc-team-worker"));
|
|
827
|
+
}
|
|
828
|
+
} catch (error) {
|
|
829
|
+
if (!isEnoent(error)) throw error;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
for (const task of await readTasks(teamDirPath)) {
|
|
833
|
+
if (task.claim?.owner !== workerId && task.assignee !== workerId) continue;
|
|
834
|
+
if (task.status === "completed" || task.status === "failed") continue;
|
|
835
|
+
await writeTask(teamDirPath, {
|
|
836
|
+
...task,
|
|
837
|
+
status: "pending",
|
|
838
|
+
assignee: undefined,
|
|
839
|
+
claim: undefined,
|
|
840
|
+
version: task.version + 1,
|
|
841
|
+
updated_at: now(),
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// Remove the stale worker record dir itself so a removable record always
|
|
846
|
+
// results in an observable removal, even when it owns no claims/tasks.
|
|
847
|
+
await fs.rm(record.path, { recursive: true, force: true });
|
|
848
|
+
return true;
|
|
849
|
+
}
|
|
680
850
|
function stateCategoryForJsonPath(filePath: string): "state" | "ledger" {
|
|
681
851
|
return filePath.endsWith(".jsonl") || filePath.includes(`${path.sep}telemetry${path.sep}`) ? "ledger" : "state";
|
|
682
852
|
}
|
|
@@ -1633,7 +1803,7 @@ function buildTeamTmuxLeaderRequirementMessage(detail?: string): string {
|
|
|
1633
1803
|
}
|
|
1634
1804
|
function readGjcTmuxProfileValue(tmuxCommand: string, sessionName: string): string {
|
|
1635
1805
|
const result = Bun.spawnSync(
|
|
1636
|
-
[tmuxCommand, "show-options", "-qv", "-t",
|
|
1806
|
+
[tmuxCommand, "show-options", "-qv", "-t", buildGjcTmuxExactOptionTarget(sessionName), GJC_TMUX_PROFILE_OPTION],
|
|
1637
1807
|
{
|
|
1638
1808
|
stdout: "pipe",
|
|
1639
1809
|
stderr: "pipe",
|
|
@@ -1645,7 +1815,14 @@ function readGjcTmuxProfileValue(tmuxCommand: string, sessionName: string): stri
|
|
|
1645
1815
|
|
|
1646
1816
|
function retagGjcLaunchedTmuxSession(tmuxCommand: string, sessionName: string): boolean {
|
|
1647
1817
|
const result = Bun.spawnSync(
|
|
1648
|
-
[
|
|
1818
|
+
[
|
|
1819
|
+
tmuxCommand,
|
|
1820
|
+
"set-option",
|
|
1821
|
+
"-t",
|
|
1822
|
+
buildGjcTmuxExactOptionTarget(sessionName),
|
|
1823
|
+
GJC_TMUX_PROFILE_OPTION,
|
|
1824
|
+
GJC_TMUX_PROFILE_VALUE,
|
|
1825
|
+
],
|
|
1649
1826
|
{
|
|
1650
1827
|
stdout: "pipe",
|
|
1651
1828
|
stderr: "pipe",
|
|
@@ -32,6 +32,20 @@ export function resolveGjcTmuxCommand(env: NodeJS.ProcessEnv = process.env): str
|
|
|
32
32
|
return env[GJC_TMUX_COMMAND_ENV]?.trim() || env.GJC_TEAM_TMUX_COMMAND?.trim() || "tmux";
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Build the exact-session target for tmux *option* commands
|
|
37
|
+
* (`show-options` / `set-option`) and `display-message -t`.
|
|
38
|
+
*
|
|
39
|
+
* Session-scoped commands such as `kill-session` / `attach-session` resolve a
|
|
40
|
+
* bare exact target (`=NAME`), but tmux 3.6a refuses to resolve a bare `=NAME`
|
|
41
|
+
* for option/display commands. Appending the empty window separator (`=NAME:`)
|
|
42
|
+
* keeps the exact-session match while giving tmux the window-qualified target
|
|
43
|
+
* those commands require. See gajae-code#580.
|
|
44
|
+
*/
|
|
45
|
+
export function buildGjcTmuxExactOptionTarget(sessionName: string): string {
|
|
46
|
+
return `=${sessionName}:`;
|
|
47
|
+
}
|
|
48
|
+
|
|
35
49
|
export const GJC_TMUX_UNTAGGED_REASON = "gjc_tmux_session_untagged";
|
|
36
50
|
|
|
37
51
|
export function buildGjcTmuxUntaggedSessionHint(tmuxCommand: string): string {
|