@gajae-code/coding-agent 0.6.1 → 0.6.4

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.
Files changed (43) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +73 -1
  3. package/dist/types/cli/update-cli.d.ts +3 -0
  4. package/dist/types/config/model-registry.d.ts +3 -0
  5. package/dist/types/config/models-config-schema.d.ts +5 -0
  6. package/dist/types/config/settings-schema.d.ts +27 -0
  7. package/dist/types/gjc-runtime/tmux-sessions.d.ts +2 -0
  8. package/dist/types/lsp/startup-events.d.ts +1 -0
  9. package/dist/types/modes/components/welcome.d.ts +3 -1
  10. package/dist/types/modes/interactive-mode.d.ts +3 -0
  11. package/dist/types/modes/prompt-action-autocomplete.d.ts +1 -0
  12. package/dist/types/skill-state/deep-interview-mutation-guard.d.ts +5 -0
  13. package/package.json +7 -7
  14. package/scripts/build-binary.ts +0 -7
  15. package/src/cli/setup-cli.ts +14 -1
  16. package/src/cli/update-cli.ts +53 -3
  17. package/src/commands/launch.ts +1 -1
  18. package/src/config/model-registry.ts +9 -2
  19. package/src/config/model-resolver.ts +13 -2
  20. package/src/config/models-config-schema.ts +1 -0
  21. package/src/config/settings-schema.ts +17 -0
  22. package/src/defaults/gjc/skills/deep-interview/SKILL.md +3 -1
  23. package/src/defaults/gjc/skills/ralplan/SKILL.md +2 -0
  24. package/src/exec/bash-executor.ts +3 -1
  25. package/src/gjc-runtime/launch-tmux.ts +62 -14
  26. package/src/gjc-runtime/state-runtime.ts +22 -14
  27. package/src/gjc-runtime/state-writer.ts +21 -1
  28. package/src/gjc-runtime/tmux-sessions.ts +36 -1
  29. package/src/internal-urls/docs-index.generated.ts +5 -6
  30. package/src/lsp/startup-events.ts +24 -0
  31. package/src/modes/components/welcome.ts +42 -9
  32. package/src/modes/controllers/input-controller.ts +21 -3
  33. package/src/modes/interactive-mode.ts +27 -19
  34. package/src/modes/prompt-action-autocomplete.ts +11 -1
  35. package/src/session/agent-session.ts +28 -20
  36. package/src/session/session-manager.ts +19 -2
  37. package/src/setup/hermes/templates/operator-instructions.v1.md +8 -0
  38. package/src/skill-state/active-state.ts +53 -30
  39. package/src/skill-state/deep-interview-mutation-guard.ts +238 -30
  40. package/src/slash-commands/builtin-registry.ts +8 -4
  41. package/src/system-prompt.ts +11 -9
  42. package/src/tools/ast-edit.ts +2 -2
  43. package/src/utils/edit-mode.ts +1 -1
@@ -1065,6 +1065,23 @@ export const SETTINGS_SCHEMA = {
1065
1065
  },
1066
1066
  },
1067
1067
 
1068
+ "startup.welcomeBannerMode": {
1069
+ type: "enum",
1070
+ values: ["auto", "unicode", "square", "ascii"] as const,
1071
+ default: "auto",
1072
+ ui: {
1073
+ tab: "interaction",
1074
+ label: "Welcome Banner Mode",
1075
+ description: "Logo style for the startup welcome screen",
1076
+ options: [
1077
+ { value: "auto", label: "Auto", description: "Use the rounded Unicode logo" },
1078
+ { value: "unicode", label: "Unicode", description: "Force the rounded Unicode logo" },
1079
+ { value: "square", label: "Square Unicode", description: "Force the square-corner Unicode fallback" },
1080
+ { value: "ascii", label: "ASCII", description: "Force the ASCII-safe logo" },
1081
+ ],
1082
+ },
1083
+ },
1084
+
1068
1085
  "startup.checkUpdate": {
1069
1086
  type: "boolean",
1070
1087
  default: true,
@@ -123,6 +123,8 @@ Deep Interview threshold: <resolvedThresholdPercent> (source: <resolvedThreshold
123
123
  - Final specs MUST resolve to `.gjc/specs/deep-interview-{slug}.md` exactly.
124
124
  - Write final specs and all ephemeral interview artifacts through the active GJC workflow/state CLI when available.
125
125
  - Direct `.gjc/` file edits are forbidden unless an explicit force override is active; do not use `write`, `edit`, or `ast_edit` against `.gjc/specs`, `.gjc/plans`, `.gjc/state`, or other `.gjc/` paths during normal workflow operation.
126
+ - Preferred: pass the spec markdown **inline** to the native deep-interview write command (`--write … --spec "<markdown>"`) — no scratch file is needed. The CLI is the only sanctioned writer for `.gjc/specs`.
127
+ - Only if a spec is too large to pass inline, stage it with the `write` tool to a system temp directory (`os.tmpdir()`/`$TMPDIR`, `/tmp`, `/var/tmp`) outside the project tree, then pass that path to `--spec`. The planning phase-boundary block tolerates these neutral temp writes; never stage interview artifacts inside the repo or under `.gjc/`, and do not improvise repo-relative scratch files.
126
128
 
127
129
  4. **Initialize state** via `gjc state write`:
128
130
 
@@ -495,7 +497,7 @@ When ambiguity ≤ threshold (or hard cap / early exit):
495
497
  - Apply `language.instruction` when present so user-facing prose in the spec preserves the session language; keep code identifiers, file paths, commands, JSON/settings keys, and quoted source text unchanged.
496
498
  - Apply the self-proofread once to newly generated spec prose before persistence, including generated natural-language table cells such as coverage notes, while preserving transcript answers, quoted/source text, code identifiers, file paths, commands, JSON/settings keys, table structure/fixed labels, and `.gjc/specs/deep-interview-{slug}.md` unchanged.
497
499
  2. **Write the final spec through the workflow CLI**: persist the artifact at `.gjc/specs/deep-interview-{slug}.md`
498
- - Always use this exact final spec path. Do not write temporary working files to the repo root or other ad hoc paths; repos may allowlist `.gjc/` for planning artifacts while protecting product branches.
500
+ - Always use this exact final spec path. Prefer passing the spec markdown **inline** as the `--spec` value; only when it is too large to pass inline, stage it as a file in a system temp directory (`os.tmpdir()`/`$TMPDIR`, `/tmp`, `/var/tmp`) outside the project tree and pass that path never write scratch specs to the repo root, the project tree, or `.gjc/`.
499
501
  - Use the native deep-interview write command with `--write --stage final --slug {slug} --spec <markdown-or-path> [--json]` for artifact and state persistence; direct `.gjc/` file edits are forbidden unless an explicit force override is active.
500
502
  - Persist the final `spec_path` in state when available so downstream skills and resumed sessions can pass the artifact path explicitly.
501
503
  - If the user preselected the deliberate ralplan path, use the native deep-interview write command with `--write --stage final --slug {slug} --spec <markdown-or-path> --deliberate [--json]` so the final spec is persisted before deep-interview hands off to ralplan.
@@ -45,6 +45,8 @@ gjc ralplan --write --stage <type> --stage_n <N> --artifact "markdown file path
45
45
 
46
46
  Use stage values that match the producer or artifact kind, such as `planner`, `architect`, `critic`, `revision`, `adr`, or `final`. Increment `--stage_n` for each consensus-loop pass. The `--artifact` value may be either a markdown file path prepared outside `.gjc/` for ingestion or the markdown content string itself. The native `--write` handler persists markdown under `.gjc/plans/ralplan/<run-id>/stage-<NN>-<stage>.md`, maintains an `index.jsonl` audit log, and for `final` stages additionally writes a `pending-approval.md` copy. Direct `write`, `edit`, or `ast_edit` calls against `.gjc/specs`, `.gjc/plans`, `.gjc/state`, or any other `.gjc/` path are forbidden unless an explicit force override is active.
47
47
 
48
+ While ralplan is active it is a pre-approval planning phase: product-code mutation tools (`write`/`edit`/`ast_edit`) and product-mutating `bash` (e.g. `tee src/...`, redirects into the project tree) are blocked, exactly like deep-interview. Prefer passing the `--artifact` markdown **inline** (the content string) so no scratch file is needed; this is mandatory for restricted role agents (see below). Only the leader, and only when an artifact is too large to pass inline, may stage it as a file in a system temp directory (`os.tmpdir()`/`$TMPDIR`, `/tmp`, `/var/tmp`) outside the project tree and pass that path — never write scratch files into the repo or `.gjc/`. Product code is mutated only after the plan is approved and execution begins.
49
+
48
50
  Restricted read-only role agents (`planner`, `architect`, and `critic`) must pass markdown content directly in `--artifact`; their restricted bash environment intentionally disables artifact file-path ingestion so a verdict command cannot persist arbitrary file contents.
49
51
 
50
52
  After a role agent persists a stage artifact, its model-facing response to the caller SHOULD be receipt-only: return the `gjc ralplan --write --json` receipt (`run_id`, `path`, `stage`, `stage_n`, `sha256`, `created_at`) plus the minimal verdict/status fields the caller needs for routing, and do **not** paste the full persisted markdown back into the parent conversation. Downstream reviewers should receive the artifact path/receipt and read the persisted file themselves when they actually need the body. This preserves the audit trail while preventing Planner/Architect/Critic verdict bodies from being duplicated into the main-agent context.
@@ -66,7 +66,9 @@ export interface BashResult {
66
66
  const shellSessions = new Map<string, Shell>();
67
67
  const brokenShellSessions = new Set<string>();
68
68
  const retiringShellSessions = new Set<Shell>();
69
- const CANCEL_CLEANUP_WAIT_MS = 250;
69
+ // Cover pi-shell's normal cancellation kill waves without turning a stalled
70
+ // native cleanup into a multi-second JavaScript tool stall.
71
+ const CANCEL_CLEANUP_WAIT_MS = 400;
70
72
 
71
73
  /** Number of persistent shell sessions currently retained (owner gauge). */
72
74
  export function getShellSessionCount(): number {
@@ -1,3 +1,4 @@
1
+ import { Buffer } from "node:buffer";
1
2
  import * as path from "node:path";
2
3
  import { safeStderrWrite } from "@gajae-code/utils";
3
4
  import type { Args } from "../cli/args";
@@ -14,7 +15,7 @@ import {
14
15
  type GjcTmuxProfileCommand,
15
16
  resolveGjcTmuxCommand,
16
17
  } from "./tmux-common";
17
- import { findGjcTmuxSessionByBranch } from "./tmux-sessions";
18
+ import { findGjcTmuxSessionByName, findGjcTmuxSessionByScope } from "./tmux-sessions";
18
19
 
19
20
  export {
20
21
  buildGjcTmuxProfileCommands,
@@ -83,6 +84,20 @@ export interface TmuxLaunchPlan {
83
84
  sessionStateFile?: string | null;
84
85
  }
85
86
 
87
+ function explicitTmuxSessionName(env: NodeJS.ProcessEnv): string | undefined {
88
+ return env.GJC_TMUX_SESSION?.trim() || undefined;
89
+ }
90
+
91
+ function findExistingSessionForLaunch(context: {
92
+ env: NodeJS.ProcessEnv;
93
+ project: string;
94
+ branch?: string | null;
95
+ }): string | undefined {
96
+ const explicit = explicitTmuxSessionName(context.env);
97
+ if (explicit) return findGjcTmuxSessionByName(explicit, context.env)?.name;
98
+ return findGjcTmuxSessionByScope(context.project, context.branch, context.env)?.name;
99
+ }
100
+
86
101
  export interface GjcTmuxProfileResult {
87
102
  skipped: boolean;
88
103
  commands: GjcTmuxProfileCommand[];
@@ -107,6 +122,7 @@ interface CommandResolutionContext {
107
122
  argv: string[];
108
123
  execPath: string;
109
124
  extraEnv?: Record<string, string>;
125
+ platform?: NodeJS.Platform;
110
126
  }
111
127
 
112
128
  function parseLaunchPolicy(env: NodeJS.ProcessEnv): LaunchPolicy {
@@ -148,6 +164,26 @@ function buildEnvAssignments(values: Record<string, string> | undefined): string
148
164
  const entries = Object.entries(values ?? {});
149
165
  return entries.length === 0 ? "" : ` ${entries.map(([key, value]) => `${key}=${shellQuote(value)}`).join(" ")}`;
150
166
  }
167
+ function powershellQuote(value: string): string {
168
+ return `'${value.replace(/'/g, "''")}'`;
169
+ }
170
+ function stripRootTmuxFlag(rawArgs: string[]): string[] {
171
+ return rawArgs.filter(arg => arg !== "--tmux");
172
+ }
173
+
174
+ function buildWindowsPowerShellInnerCommand(context: CommandResolutionContext, rawArgs: string[]): string {
175
+ const command = resolveCurrentGjcCommand(context);
176
+ const envLines = Object.entries({ [GJC_TMUX_LAUNCHED_ENV]: "1", ...(context.extraEnv ?? {}) }).map(
177
+ ([key, value]) => `$env:${key} = ${powershellQuote(value)}`,
178
+ );
179
+ const invocation = ["&", ...command.map(powershellQuote), ...stripRootTmuxFlag(rawArgs).map(powershellQuote)].join(
180
+ " ",
181
+ );
182
+ const exitLine = "if ($null -ne $LASTEXITCODE) { exit $LASTEXITCODE } else { exit 1 }";
183
+ const script = [...envLines, invocation, exitLine].join("\n");
184
+ const encodedCommand = Buffer.from(script, "utf16le").toString("base64");
185
+ return `pwsh -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encodedCommand}`;
186
+ }
151
187
 
152
188
  export function applyGjcTmuxProfile(context: GjcTmuxProfileContext): GjcTmuxProfileResult {
153
189
  const env = context.env ?? process.env;
@@ -177,16 +213,26 @@ function resolveCurrentGjcCommand(context: CommandResolutionContext): string[] {
177
213
  if (isBunVirtualPath(entrypoint)) {
178
214
  return isBunVirtualPath(context.execPath) ? ["gjc"] : [context.execPath];
179
215
  }
180
- const resolvedEntrypoint = path.isAbsolute(entrypoint) ? entrypoint : path.resolve(context.cwd, entrypoint);
216
+ const pathModule = pathModuleForPlatform(context.platform);
217
+ const resolvedEntrypoint = pathModule.isAbsolute(entrypoint)
218
+ ? entrypoint
219
+ : pathModule.resolve(context.cwd, entrypoint);
181
220
  if (entrypoint.endsWith(".ts") || entrypoint.endsWith(".js") || entrypoint.endsWith(".mjs")) {
182
221
  return [context.execPath, resolvedEntrypoint];
183
222
  }
184
223
  return [resolvedEntrypoint];
185
224
  }
225
+ function isWindowsPlatform(platform: NodeJS.Platform | undefined): boolean {
226
+ return platform === "win32";
227
+ }
228
+ function pathModuleForPlatform(platform: NodeJS.Platform | undefined): typeof path.win32 | typeof path {
229
+ return isWindowsPlatform(platform) ? path.win32 : path;
230
+ }
186
231
 
187
232
  function buildInnerCommand(context: CommandResolutionContext, rawArgs: string[]): string {
233
+ if (isWindowsPlatform(context.platform)) return buildWindowsPowerShellInnerCommand(context, rawArgs);
188
234
  const command = resolveCurrentGjcCommand(context);
189
- const quoted = [...command, ...rawArgs].map(shellQuote).join(" ");
235
+ const quoted = [...command, ...stripRootTmuxFlag(rawArgs)].map(shellQuote).join(" ");
190
236
  return `exec env ${GJC_TMUX_LAUNCHED_ENV}=1${buildEnvAssignments(context.extraEnv)} ${quoted}`;
191
237
  }
192
238
 
@@ -305,7 +351,6 @@ export function buildDefaultTmuxLaunchPlan(context: TmuxLaunchContext): TmuxLaun
305
351
  if (!context.parsed.tmux || policy === "direct") return undefined;
306
352
  if (env.TMUX || env[GJC_TMUX_LAUNCHED_ENV] === "1") return undefined;
307
353
  const platform = context.platform ?? process.platform;
308
- if (platform === "win32") return undefined;
309
354
  const tty = context.tty ?? { stdin: Boolean(process.stdin.isTTY), stdout: Boolean(process.stdout.isTTY) };
310
355
  if (policy === "tmux" && !isInteractiveRootLaunch(context.parsed, tty)) return undefined;
311
356
 
@@ -320,12 +365,14 @@ export function buildDefaultTmuxLaunchPlan(context: TmuxLaunchContext): TmuxLaun
320
365
  path.join(cwd, ".gjc", "runtime", "tmux-sessions", `${buildGjcTmuxSessionSlug(sessionName)}.json`);
321
366
  const tmuxAvailable = context.tmuxAvailable ?? Bun.which(tmuxCommand) !== null;
322
367
  if (!tmuxAvailable) return undefined;
323
- const existingBranchSessionName =
368
+ const existingSessionName =
324
369
  "existingBranchSessionName" in context
325
370
  ? (context.existingBranchSessionName ?? undefined)
326
- : context.worktreeBranch
327
- ? findGjcTmuxSessionByBranch(context.worktreeBranch, env, project)?.name
328
- : undefined;
371
+ : findExistingSessionForLaunch({
372
+ env,
373
+ project,
374
+ branch,
375
+ });
329
376
  const innerCommand = buildInnerCommand(
330
377
  {
331
378
  cwd,
@@ -335,6 +382,7 @@ export function buildDefaultTmuxLaunchPlan(context: TmuxLaunchContext): TmuxLaun
335
382
  [GJC_COORDINATOR_SESSION_ID_ENV]: sessionId,
336
383
  [GJC_COORDINATOR_SESSION_STATE_FILE_ENV]: sessionStateFile,
337
384
  },
385
+ platform,
338
386
  },
339
387
  context.rawArgs,
340
388
  );
@@ -348,7 +396,7 @@ export function buildDefaultTmuxLaunchPlan(context: TmuxLaunchContext): TmuxLaun
348
396
  project,
349
397
  sessionId,
350
398
  sessionStateFile,
351
- attachSessionName: existingBranchSessionName,
399
+ attachSessionName: existingSessionName,
352
400
  };
353
401
  }
354
402
 
@@ -405,19 +453,19 @@ export function launchDefaultTmuxIfNeeded(context: TmuxLaunchContext): boolean {
405
453
  sessionId: plan.sessionId ?? null,
406
454
  sessionStateFile: plan.sessionStateFile ?? null,
407
455
  });
408
- if (profile.failures.length > 0) {
456
+ const ownershipFailure = profile.failures.find(item => item.command.args.includes("@gjc-profile"));
457
+ if (ownershipFailure) {
409
458
  cleanupCreatedTmuxSession(plan, spawnSync, options);
410
- const failure =
411
- profile.failures.find(item => item.command.args.includes("@gjc-profile")) ?? profile.failures[0];
412
459
  (context.diagnosticWriter ?? safeStderrWrite)(
413
- formatTmuxLaunchDiagnostic("profile tagging failed", failure?.stderr),
460
+ formatTmuxLaunchDiagnostic("profile tagging failed", ownershipFailure.stderr),
414
461
  );
415
462
  return true;
416
463
  }
417
464
  }
418
465
  if (created.exitCode !== 0) return false;
419
- const attached = spawnSync(plan.tmuxCommand, ["attach-session", "-t", plan.sessionName], options);
466
+ const attached = spawnSync(plan.tmuxCommand, ["attach-session", "-t", `=${plan.sessionName}`], options);
420
467
  if (attached.exitCode === 0) return true;
468
+ cleanupCreatedTmuxSession(plan, spawnSync, options);
421
469
  (context.diagnosticWriter ?? safeStderrWrite)(formatTmuxLaunchDiagnostic("attach failed", attached.stderr));
422
470
  return true;
423
471
  }
@@ -297,12 +297,26 @@ async function resolveSelectors(
297
297
  }
298
298
  if (mode) assertKnownMode(mode);
299
299
 
300
+ const sessionId = resolveSessionIdFromArgs(args, payload);
301
+
302
+ const threadId = flagValue(args, "--thread-id")?.trim() || undefined;
303
+ if (threadId) assertSafePathComponent(threadId, "thread-id");
304
+ const turnId = flagValue(args, "--turn-id")?.trim() || undefined;
305
+ if (turnId) assertSafePathComponent(turnId, "turn-id");
306
+
307
+ return { mode: mode as CanonicalGjcWorkflowSkill | undefined, sessionId, threadId, turnId, payload };
308
+ }
309
+
310
+ // Session-id resolution order: explicit --session-id flag, then payload
311
+ // session_id, then GJC_SESSION_ID env var (set by AgentSession.sdk for
312
+ // agent-initiated CLI invocations). The env-var default keeps shell
313
+ // snippets in skill docs short while still routing state commands to the
314
+ // caller's session-scoped state files.
315
+ function resolveSessionIdFromArgs(
316
+ args: readonly string[],
317
+ payload: Record<string, unknown> | undefined,
318
+ ): string | undefined {
300
319
  const explicitSessionId = flagValue(args, "--session-id");
301
- // Session-id resolution order: explicit --session-id flag, then payload
302
- // session_id, then GJC_SESSION_ID env var (set by AgentSession.sdk for
303
- // agent-initiated CLI invocations). The env-var default keeps shell
304
- // snippets in skill docs short while still routing writes/reads to the
305
- // caller's session-scoped state files.
306
320
  let sessionId = explicitSessionId !== undefined ? explicitSessionId.trim() || undefined : undefined;
307
321
  if (!sessionId && payload && typeof payload.session_id === "string") {
308
322
  sessionId = payload.session_id.trim() || undefined;
@@ -312,13 +326,7 @@ async function resolveSelectors(
312
326
  if (envSessionId) sessionId = envSessionId;
313
327
  }
314
328
  if (sessionId) assertSafePathComponent(sessionId, "session-id");
315
-
316
- const threadId = flagValue(args, "--thread-id")?.trim() || undefined;
317
- if (threadId) assertSafePathComponent(threadId, "thread-id");
318
- const turnId = flagValue(args, "--turn-id")?.trim() || undefined;
319
- if (turnId) assertSafePathComponent(turnId, "turn-id");
320
-
321
- return { mode: mode as CanonicalGjcWorkflowSkill | undefined, sessionId, threadId, turnId, payload };
329
+ return sessionId;
322
330
  }
323
331
 
324
332
  async function inferModeFromActiveState(
@@ -718,8 +726,8 @@ async function handleDoctor(
718
726
  ): Promise<StateCommandResult> {
719
727
  const rawSkill = flagValue(args, "--skill")?.trim() || flagValue(args, "--mode")?.trim() || positionalSkill?.trim();
720
728
  if (rawSkill) assertKnownMode(rawSkill);
721
- const sessionId = flagValue(args, "--session-id")?.trim() || undefined;
722
- if (sessionId) assertSafePathComponent(sessionId, "session-id");
729
+ const payload = await readInputJson(flagValue(args, "--input"), cwd);
730
+ const sessionId = resolveSessionIdFromArgs(args, payload);
723
731
  const summary = await collectDoctorSummary(cwd, rawSkill as CanonicalGjcWorkflowSkill | undefined, sessionId);
724
732
  return {
725
733
  status: summary.ok ? 0 : 1,
@@ -298,8 +298,28 @@ function flattenActiveSubskills(entries: SkillActiveEntry[]): ActiveSubskillEntr
298
298
  return [...deduped.values()];
299
299
  }
300
300
 
301
+ const CANONICAL_PIPELINE_RANK = new Map<string, number>([
302
+ ["deep-interview", 0],
303
+ ["ralplan", 1],
304
+ ["ultragoal", 2],
305
+ ]);
306
+
307
+ function canonicalPipelineRank(skill: string): number | undefined {
308
+ return CANONICAL_PIPELINE_RANK.get(skill);
309
+ }
310
+
311
+ function compareActiveEntryPrimary(a: SkillActiveEntry, b: SkillActiveEntry): number {
312
+ const aRank = canonicalPipelineRank(a.skill);
313
+ const bRank = canonicalPipelineRank(b.skill);
314
+ if (aRank !== undefined || bRank !== undefined) return (bRank ?? -1) - (aRank ?? -1);
315
+ const aTime = Date.parse(safeString(a.updated_at));
316
+ const bTime = Date.parse(safeString(b.updated_at));
317
+ if (Number.isFinite(aTime) || Number.isFinite(bTime)) return (bTime || 0) - (aTime || 0);
318
+ return 0;
319
+ }
320
+
301
321
  function buildActiveSnapshot(entries: SkillActiveEntry[]): SkillActiveState {
302
- const visible = entries.filter(entry => entry.active !== false);
322
+ const visible = entries.filter(entry => entry.active !== false).toSorted(compareActiveEntryPrimary);
303
323
  const primary = visible[0];
304
324
  return {
305
325
  version: 1,
@@ -137,6 +137,8 @@ function listRawTmuxSessionNames(env: NodeJS.ProcessEnv = process.env): string[]
137
137
  export function listGjcTmuxSessions(env: NodeJS.ProcessEnv = process.env): GjcTmuxSessionStatus[] {
138
138
  return listSessionLines(env)
139
139
  .map(parseSessionLine)
140
+ .filter((session): session is GjcTmuxSessionStatus => session != null)
141
+ .map(session => hydrateSessionFromExactOptions(session, env))
140
142
  .filter((session): session is GjcTmuxSessionStatus => session?.profile === GJC_TMUX_PROFILE_VALUE)
141
143
  .sort((a, b) => a.name.localeCompare(b.name));
142
144
  }
@@ -145,7 +147,8 @@ export function listGjcTmuxSessions(env: NodeJS.ProcessEnv = process.env): GjcTm
145
147
  export function listTmuxSessionsForGc(env: NodeJS.ProcessEnv = process.env): GjcTmuxSessionsForGc {
146
148
  const sessions = listSessionLines(env)
147
149
  .map(parseSessionLine)
148
- .filter((session): session is GjcTmuxSessionStatus => session != null);
150
+ .filter((session): session is GjcTmuxSessionStatus => session != null)
151
+ .map(session => hydrateSessionFromExactOptions(session, env));
149
152
  const tagged = sessions
150
153
  .filter(session => session.profile === GJC_TMUX_PROFILE_VALUE)
151
154
  .sort((a, b) => a.name.localeCompare(b.name));
@@ -179,6 +182,22 @@ export function findGjcTmuxSessionByBranch(
179
182
  );
180
183
  }
181
184
 
185
+ export function findGjcTmuxSessionByName(
186
+ sessionName: string,
187
+ env: NodeJS.ProcessEnv = process.env,
188
+ ): GjcTmuxSessionStatus | undefined {
189
+ return listGjcTmuxSessions(env).find(session => session.name === sessionName);
190
+ }
191
+
192
+ export function findGjcTmuxSessionByScope(
193
+ project: string,
194
+ branch: string | null | undefined,
195
+ env: NodeJS.ProcessEnv = process.env,
196
+ ): GjcTmuxSessionStatus | undefined {
197
+ return listGjcTmuxSessions(env).find(
198
+ session => session.project === project && (branch ? session.branch === branch : session.branch === undefined),
199
+ );
200
+ }
182
201
  export function statusGjcTmuxSession(sessionName: string, env: NodeJS.ProcessEnv = process.env): GjcTmuxSessionStatus {
183
202
  const session = listGjcTmuxSessions(env).find(candidate => candidate.name === sessionName);
184
203
  if (session) return session;
@@ -227,6 +246,22 @@ function readExactOptionForGc(sessionName: string, option: string, env: NodeJS.P
227
246
  }
228
247
  }
229
248
 
249
+ function hydrateSessionFromExactOptions(session: GjcTmuxSessionStatus, env: NodeJS.ProcessEnv): GjcTmuxSessionStatus {
250
+ if (session.profile === GJC_TMUX_PROFILE_VALUE) return session;
251
+ const profile = readExactOptionForGc(session.name, GJC_TMUX_PROFILE_OPTION, env);
252
+ if (profile !== GJC_TMUX_PROFILE_VALUE) return session;
253
+ return {
254
+ ...session,
255
+ profile,
256
+ branch: session.branch ?? readExactOptionForGc(session.name, GJC_TMUX_BRANCH_OPTION, env),
257
+ branchSlug: session.branchSlug ?? readExactOptionForGc(session.name, GJC_TMUX_BRANCH_SLUG_OPTION, env),
258
+ project: session.project ?? readExactOptionForGc(session.name, GJC_TMUX_PROJECT_OPTION, env),
259
+ sessionId: session.sessionId ?? readExactOptionForGc(session.name, GJC_TMUX_SESSION_ID_OPTION, env),
260
+ sessionStateFile:
261
+ session.sessionStateFile ?? readExactOptionForGc(session.name, GJC_TMUX_SESSION_STATE_FILE_OPTION, env),
262
+ };
263
+ }
264
+
230
265
  /** @internal */
231
266
  export function readTmuxSessionTagsForGc(
232
267
  sessionName: string,