@gajae-code/coding-agent 0.15.4 → 0.15.5

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 (42) hide show
  1. package/CHANGELOG.md +20 -1
  2. package/dist/types/cli/update-cli.d.ts +25 -5
  3. package/dist/types/config/autorouting-tier-map.d.ts +3 -0
  4. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  5. package/dist/types/extensibility/shared-events.d.ts +2 -0
  6. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +8 -1
  7. package/dist/types/sdk/cli/session-cli.d.ts +42 -1
  8. package/dist/types/sdk/host/host.d.ts +2 -0
  9. package/dist/types/sdk/host/session-runtime.d.ts +4 -0
  10. package/dist/types/session/agent-session.d.ts +4 -2
  11. package/dist/types/session/internal/managed-session-storage.d.ts +4 -3
  12. package/dist/types/tools/path-utils.d.ts +5 -6
  13. package/dist/types/tools/tmux-self-injection-guard.d.ts +15 -0
  14. package/package.json +7 -7
  15. package/src/cli/update-cli.ts +102 -1
  16. package/src/commands/sdk.ts +5 -0
  17. package/src/config/autorouting-tier-map.ts +1 -0
  18. package/src/config/model-profiles.ts +5 -5
  19. package/src/config/model-registry.ts +10 -4
  20. package/src/coordinator-mcp/policy.ts +1 -1
  21. package/src/extensibility/extensions/types.ts +3 -0
  22. package/src/extensibility/shared-events.ts +2 -0
  23. package/src/gjc-runtime/launch-worktree.ts +44 -7
  24. package/src/gjc-runtime/ralplan-runtime.ts +14 -1
  25. package/src/gjc-runtime/tmux-sessions.ts +145 -48
  26. package/src/internal-urls/docs-index.generated.ts +7 -7
  27. package/src/modes/prompt-action-autocomplete.ts +26 -47
  28. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  29. package/src/sdk/bus/kind-aware-reconciliation.ts +10 -2
  30. package/src/sdk/bus/prompt-reconciliation.ts +5 -2
  31. package/src/sdk/bus/telegram-daemon-contract.ts +8 -1
  32. package/src/sdk/cli/session-cli.ts +149 -4
  33. package/src/sdk/host/host.ts +7 -0
  34. package/src/sdk/host/session-runtime.test.ts +821 -22
  35. package/src/sdk/host/session-runtime.ts +669 -67
  36. package/src/sdk/router/session-router.ts +69 -15
  37. package/src/session/agent-session.ts +232 -15
  38. package/src/session/internal/managed-session-storage.ts +64 -26
  39. package/src/session/session-manager.ts +26 -8
  40. package/src/tools/bash.ts +14 -0
  41. package/src/tools/path-utils.ts +77 -29
  42. package/src/tools/tmux-self-injection-guard.ts +359 -0
@@ -536,6 +536,7 @@ export interface ExtensionContext {
536
536
  onPreflightAcceptCommit?: () => void | Promise<void>;
537
537
  onSkillPrepared?: (meta: { name: string; path: string; lineCount?: number; cleanedArgs?: string }) => void;
538
538
  preflightSignal?: AbortSignal;
539
+ sdkRunToken?: string;
539
540
  },
540
541
  ): Promise<unknown>;
541
542
  setPlanMode?(on: boolean): unknown;
@@ -754,6 +755,8 @@ export interface AgentStartEvent extends SharedAgentStartEvent {
754
755
  export interface AgentFailedEvent {
755
756
  type: "agent_failed";
756
757
  error: AgentFailureDiagnostic;
758
+ /** Internal SDK queue-owner binding for exact lifecycle attribution. */
759
+ sdkRunToken?: string;
757
760
  /** Attempt correlation for the failing run, when scoped (matches the
758
761
  * agent_start/agent_end scope contract). */
759
762
  scope?: AttemptScope;
@@ -184,6 +184,8 @@ export interface AgentStartEvent {
184
184
  export interface AgentEndEvent {
185
185
  type: "agent_end";
186
186
  messages: AgentMessage[];
187
+ /** Internal SDK queue-owner binding for exact lifecycle attribution. */
188
+ sdkRunToken?: string;
187
189
  /** Indicates whether the loop ended normally, suspended, cancelled, or entered maintenance. */
188
190
  stopReason?: "completed" | "paused" | "cancelled" | "maintenance";
189
191
  /** Present for maintenance checkpoints; non-aborted checkpoints are not terminal. */
@@ -78,7 +78,7 @@ const WORKTREE_BUCKET_ENV = "GJC_WORKTREE_DIR";
78
78
  * what keeps two repositories that share a branch name from resolving to one worktree.
79
79
  */
80
80
  const REPO_NAME_PLACEHOLDER = "{repo}";
81
- const DEFAULT_WORKTREE_BUCKET = `${REPO_NAME_PLACEHOLDER}.gajae-code-worktrees`;
81
+ const DEFAULT_WORKTREE_BUCKET = `${REPO_NAME_PLACEHOLDER}/.worktrees`;
82
82
 
83
83
  function expandHomePrefix(value: string, home: string, pathApi: typeof path.posix): string {
84
84
  if (value === "~") return home;
@@ -108,14 +108,36 @@ export function resolveWorktreeBucketForPath(
108
108
  /**
109
109
  * Directory that holds this repository's launch worktrees.
110
110
  *
111
- * A relative override resolves against the repository's PARENT directory, matching the
112
- * default's own shape, so `{repo}.worktrees` adopts an existing sibling bucket and
113
- * `.worktrees` parks a hidden bucket beside the repository. An absolute override is used verbatim.
111
+ * A relative override resolves against the repository's parent directory. The default
112
+ * `{repo}/.worktrees` template places managed worktrees inside the repository, while
113
+ * `{repo}.worktrees` adopts an existing sibling bucket. An absolute override is used verbatim.
114
114
  */
115
115
  function resolveWorktreeBucket(repoRoot: string): string {
116
116
  return resolveWorktreeBucketForPath(repoRoot, process.env[WORKTREE_BUCKET_ENV], os.homedir(), path);
117
117
  }
118
118
 
119
+ function ensureRepositoryBucketIgnored(repoRoot: string, bucketPath: string): void {
120
+ const relativeBucket = path.relative(repoRoot, bucketPath);
121
+ if (relativeBucket.startsWith(`..${path.sep}`) || relativeBucket === ".." || path.isAbsolute(relativeBucket)) return;
122
+ const ignoreProbe = path.join(relativeBucket, ".gjc-worktree-probe");
123
+ for (const candidate of [relativeBucket, ignoreProbe]) {
124
+ const result = Bun.spawnSync(["git", "check-ignore", "--quiet", "--", candidate], {
125
+ cwd: repoRoot,
126
+ stdout: "ignore",
127
+ stderr: "pipe",
128
+ });
129
+ if (result.exitCode === 0) return;
130
+ }
131
+ throw new Error(
132
+ [
133
+ "worktree_bucket_not_ignored",
134
+ "The GJC launch worktree bucket is inside the repository but is not ignored by Git.",
135
+ `Path: ${formatBucketPath(bucketPath)}`,
136
+ `Safe remediation: add /${relativeBucket.replaceAll(path.sep, "/")} to ${path.join(repoRoot, ".gitignore")}, then relaunch.`,
137
+ ].join("\n"),
138
+ );
139
+ }
140
+
119
141
  function resolveSourceBranchSlug(repoRoot: string, baseRef: string): string {
120
142
  const branch = tryRunGit(repoRoot, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
121
143
  if (branch) return sanitizePathToken(branch);
@@ -301,6 +323,7 @@ function pruneStaleWorktreePath(repoRoot: string): void {
301
323
 
302
324
  function readWorktreeEntryFromPath(repoRoot: string, worktreePath: string): GitWorktreeEntry | null {
303
325
  if (!fs.existsSync(worktreePath)) return null;
326
+ if (!fs.existsSync(path.join(worktreePath, ".git"))) return null;
304
327
  const repoCommonDir = tryRunGit(repoRoot, ["rev-parse", "--git-common-dir"]);
305
328
  const worktreeCommonDir = tryRunGit(worktreePath, ["rev-parse", "--git-common-dir"]);
306
329
  if (!repoCommonDir || !worktreeCommonDir) return null;
@@ -379,6 +402,7 @@ export function planLaunchWorktree(
379
402
  if (branchName) validateBranchName(repoRoot, branchName);
380
403
  const worktreeSlug = mode.detached ? resolveSourceBranchSlug(repoRoot, baseRef) : sanitizePathToken(mode.name);
381
404
  const worktreePath = path.join(resolveWorktreeBucket(repoRoot), worktreeSlug);
405
+ if (path.resolve(worktreePath) === path.resolve(repoRoot)) throw new Error(`worktree_path_conflict:${worktreePath}`);
382
406
  return { enabled: true, repoRoot, worktreePath, detached: mode.detached, baseRef, branchName };
383
407
  }
384
408
 
@@ -386,6 +410,9 @@ export function ensureLaunchWorktree(
386
410
  plan: GjcLaunchWorktreePlan | { enabled: false },
387
411
  ): GjcLaunchWorktreeResult | { enabled: false } {
388
412
  if (!plan.enabled) return { enabled: false };
413
+ const bucketPath = path.dirname(plan.worktreePath);
414
+ inspectBucketDir(bucketPath);
415
+ ensureRepositoryBucketIgnored(plan.repoRoot, bucketPath);
389
416
  let allWorktrees = listWorktrees(plan.repoRoot);
390
417
  const staleAtPath = findWorktreeByPath(allWorktrees, plan.worktreePath);
391
418
  if (staleAtPath && !fs.existsSync(staleAtPath.path)) {
@@ -393,9 +420,19 @@ export function ensureLaunchWorktree(
393
420
  allWorktrees = listWorktrees(plan.repoRoot);
394
421
  }
395
422
 
396
- const existingAtPath =
397
- findWorktreeByPath(allWorktrees, plan.worktreePath) ??
398
- readWorktreeEntryFromPath(plan.repoRoot, plan.worktreePath);
423
+ const listedAtPath = findWorktreeByPath(allWorktrees, plan.worktreePath);
424
+ const existingAtPath = readWorktreeEntryFromPath(plan.repoRoot, plan.worktreePath);
425
+ if (listedAtPath && !existingAtPath) {
426
+ if (fs.existsSync(plan.worktreePath)) throw new Error(`worktree_path_conflict:${plan.worktreePath}`);
427
+ throw new Error(
428
+ [
429
+ "worktree_path_unavailable",
430
+ "The requested launch worktree is still registered by Git but its directory is unavailable or locked.",
431
+ `Path: ${formatBucketPath(plan.worktreePath)}`,
432
+ "Safe remediation: inspect the worktree lock and remove or repair it with git worktree remove/prune when it is no longer needed, then relaunch. GJC did not delete or replace the entry.",
433
+ ].join("\n"),
434
+ );
435
+ }
399
436
  const expectedBranchRef = plan.branchName ? `refs/heads/${plan.branchName}` : null;
400
437
 
401
438
  if (existingAtPath) {
@@ -756,9 +756,22 @@ export async function resolveRalplanTargetRoot(
756
756
  stdout: "pipe",
757
757
  stderr: "pipe",
758
758
  });
759
- if ((await verified.exited) !== 0) {
759
+ const [verifiedStatus, verifiedOutput] = await Promise.all([verified.exited, new Response(verified.stdout).text()]);
760
+ const headObject = verifiedOutput.trim();
761
+ if (verifiedStatus !== 0 || !/^[0-9a-f]{40,64}$/u.test(headObject)) {
760
762
  throw new RalplanCommandError(2, `ralplan --worktree-root is not a valid git worktree: ${canonical}`);
761
763
  }
764
+ const objectType = Bun.spawn(["git", "-C", canonical, "cat-file", "-t", headObject], {
765
+ stdout: "pipe",
766
+ stderr: "pipe",
767
+ });
768
+ const [objectTypeStatus, objectTypeOutput] = await Promise.all([
769
+ objectType.exited,
770
+ new Response(objectType.stdout).text(),
771
+ ]);
772
+ if (objectTypeStatus !== 0 || objectTypeOutput.trim() !== "commit") {
773
+ throw new RalplanCommandError(2, `ralplan --worktree-root is not a commit worktree: ${canonical}`);
774
+ }
762
775
  let worktreeRoot: string;
763
776
  try {
764
777
  worktreeRoot = await fs.realpath(repository.repoRoot);
@@ -153,6 +153,8 @@ export interface ForceCloseOwnerDependencies {
153
153
  waitForOwnerExitVerdict?(): Promise<OwnerVerdict>;
154
154
  }
155
155
  const GJC_TMUX_PSMUX_INCARNATION_OPTION = "@gjc-psmux-incarnation";
156
+ const effectiveSessionEnvironments = new WeakMap<GjcTmuxSessionStatus, NodeJS.ProcessEnv>();
157
+ const fallbackSessionEnvironments = new WeakSet<GjcTmuxSessionStatus>();
156
158
 
157
159
  const FORCE_CLOSE_VERDICT_TIMEOUT_MS = 15_000;
158
160
  const FORCE_CLOSE_VERDICT_POLL_MS = 50;
@@ -305,20 +307,79 @@ function parseSessionLine(line: string): GjcTmuxSessionStatus | null {
305
307
  };
306
308
  }
307
309
 
308
- function runListSessions(format: string, env: NodeJS.ProcessEnv = process.env): string[] {
310
+ /** tmux failure shapes that mean "this socket has no server", not "tmux is broken". */
311
+ function isMissingServerFailure(message: string): boolean {
312
+ return (
313
+ message.includes("no server running") ||
314
+ (message.includes("failed to connect to server") && /(?:no such file or directory|not found)/iu.test(message))
315
+ );
316
+ }
317
+
318
+ function isMissingSocketFailure(message: string): boolean {
319
+ return (
320
+ message.toLowerCase().includes("error connecting to ") &&
321
+ /(?:no such file or directory|not found)/iu.test(message)
322
+ );
323
+ }
324
+
325
+ /**
326
+ * `$TMUX` is inherited from whatever launched gjc and names the socket tmux
327
+ * talks to. Terminal hosts that emulate tmux for their own agents can export a
328
+ * `$TMUX` pointing at a socket they never created, and tmux then fails with
329
+ * `error connecting to <socket>` — the same message shape as a legitimate
330
+ * "no server" miss. Returning the socket path lets callers tell the two apart
331
+ * instead of reporting zero sessions while sessions are live on the default
332
+ * socket.
333
+ */
334
+ function inheritedTmuxSocketPath(env: NodeJS.ProcessEnv): string | null {
335
+ const socket = env.TMUX?.split(",")[0]?.trim();
336
+ return socket ? socket : null;
337
+ }
338
+
339
+ function namesInheritedTmuxSocket(message: string, socket: string): boolean {
340
+ const normalizedMessage = process.platform === "win32" ? message.toLowerCase() : message;
341
+ const normalizedSocket = process.platform === "win32" ? socket.toLowerCase() : socket;
342
+ return (
343
+ (normalizedMessage.includes(`error connecting to ${normalizedSocket} `) ||
344
+ normalizedMessage.includes(`failed to connect to server: ${normalizedSocket} `)) &&
345
+ /(?:no such file or directory|not found)/iu.test(message)
346
+ );
347
+ }
348
+
349
+ function envWithoutInheritedTmux(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
350
+ const withoutTmux: NodeJS.ProcessEnv = { ...env };
351
+ delete withoutTmux.TMUX;
352
+ return withoutTmux;
353
+ }
354
+
355
+ interface ListedTmuxSessions {
356
+ lines: string[];
357
+ env: NodeJS.ProcessEnv;
358
+ }
359
+
360
+ function runListSessions(format: string, env: NodeJS.ProcessEnv = process.env): ListedTmuxSessions {
361
+ let effectiveEnv = env;
309
362
  let output = "";
310
363
  try {
311
364
  output = runTmux(["list-sessions", "-F", format], env);
312
365
  } catch (error) {
313
366
  const message = error instanceof Error ? error.message : String(error);
314
- if (
315
- message.includes("no server running") ||
316
- message.includes("failed to connect to server") ||
317
- message.includes("error connecting to")
318
- ) {
319
- return [];
320
- }
321
- throw error;
367
+ const inheritedSocket = inheritedTmuxSocketPath(env);
368
+ // A miss on the default socket is a real empty list; only the inherited
369
+ // socket is suspect, so retry without it before concluding there is
370
+ // nothing to list.
371
+ if (inheritedSocket && namesInheritedTmuxSocket(message, inheritedSocket)) {
372
+ try {
373
+ effectiveEnv = envWithoutInheritedTmux(env);
374
+ output = runTmux(["list-sessions", "-F", format], effectiveEnv);
375
+ } catch (retryError) {
376
+ const retryMessage = retryError instanceof Error ? retryError.message : String(retryError);
377
+ if (isMissingServerFailure(retryMessage) || isMissingSocketFailure(retryMessage))
378
+ return { lines: [], env: effectiveEnv };
379
+ throw retryError;
380
+ }
381
+ } else if (isMissingServerFailure(message) || isMissingSocketFailure(message)) return { lines: [], env };
382
+ else throw error;
322
383
  }
323
384
  const lines = output
324
385
  .split("\n")
@@ -333,20 +394,25 @@ function runListSessions(format: string, env: NodeJS.ProcessEnv = process.env):
333
394
  if (lines.length > 0 && !lines[0].includes("\t")) {
334
395
  const binary = resolveGjcTmuxBinary({ env });
335
396
  if (binary.isPsmux) {
336
- return lines.map(line => {
337
- const match = line.match(/^([^:]+):\s*(\d+)\s+windows?\s+\(created\s+([^)]+)\)/);
338
- if (!match) return line;
339
- const [, name, windows, created] = match;
340
- const createdEpoch = String(Math.floor(new Date(`${created} UTC`).getTime() / 1000) || 0);
341
-
342
- return [name, windows, "0", createdEpoch, "", "", "0", "", "", "", "", "", "", "", "", "", ""].join("\t");
343
- });
397
+ return {
398
+ lines: lines.map(line => {
399
+ const match = line.match(/^([^:]+):\s*(\d+)\s+windows?\s+\(created\s+([^)]+)\)/);
400
+ if (!match) return line;
401
+ const [, name, windows, created] = match;
402
+ const createdEpoch = String(Math.floor(new Date(`${created} UTC`).getTime() / 1000) || 0);
403
+
404
+ return [name, windows, "0", createdEpoch, "", "", "0", "", "", "", "", "", "", "", "", "", ""].join(
405
+ "\t",
406
+ );
407
+ }),
408
+ env: effectiveEnv,
409
+ };
344
410
  }
345
411
  }
346
- return lines;
412
+ return { lines, env: effectiveEnv };
347
413
  }
348
414
 
349
- function listSessionLines(env: NodeJS.ProcessEnv = process.env): string[] {
415
+ function listSessionLines(env: NodeJS.ProcessEnv = process.env): ListedTmuxSessions {
350
416
  return runListSessions(
351
417
  `#{session_name}\t#{session_windows}\t#{session_attached}\t#{session_created}\t#{${GJC_TMUX_PROFILE_OPTION}}\t#{session_key_table}\t#{session_panes}\t#{pane_pid}\t#{${GJC_TMUX_BRANCH_OPTION}}\t#{${GJC_TMUX_BRANCH_SLUG_OPTION}}\t#{${GJC_TMUX_PROJECT_OPTION}}\t#{${GJC_TMUX_SESSION_ID_OPTION}}\t#{${GJC_TMUX_SESSION_STATE_FILE_OPTION}}\t#{${GJC_TMUX_OWNER_GENERATION_OPTION}}\t#{${GJC_TMUX_VERSION_OPTION}}\t#{${GJC_TMUX_PSMUX_INCARNATION_OPTION}}\t#{session_id}`,
352
418
 
@@ -355,8 +421,13 @@ function listSessionLines(env: NodeJS.ProcessEnv = process.env): string[] {
355
421
  }
356
422
 
357
423
  function listRawTmuxSessionNames(env: NodeJS.ProcessEnv = process.env): string[] {
358
- return runListSessions("#{session_name}", env).map(line => line.split("\t")[0] ?? line);
424
+ return runListSessions("#{session_name}", env).lines.map(line => line.split("\t")[0] ?? line);
425
+ }
426
+
427
+ function assertMutationAuthority(session: GjcTmuxSessionStatus): void {
428
+ if (fallbackSessionEnvironments.has(session)) throw new Error("gjc_tmux_fallback_authority_unconfirmed");
359
429
  }
430
+
360
431
  function canonicalProviderStateDirs(cwd: string): string[] {
361
432
  const gjcDir = path.join(cwd, GJC_DIR);
362
433
  let entries: fsSync.Dirent[];
@@ -424,13 +495,24 @@ function psmuxAuthorityEnvironments(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv[]
424
495
 
425
496
  export function listGjcTmuxSessions(env: NodeJS.ProcessEnv = process.env): GjcTmuxSessionStatus[] {
426
497
  const discovered = psmuxAuthorityEnvironments(env).flatMap(authorityEnv => {
427
- const authority = psmuxAuthorityFromEnv(authorityEnv) ?? undefined;
428
- return listSessionLines(authorityEnv)
498
+ const listed = listSessionLines(authorityEnv);
499
+ const authority = psmuxAuthorityFromEnv(listed.env) ?? undefined;
500
+ return listed.lines
429
501
  .map(parseSessionLine)
430
502
  .filter((session): session is GjcTmuxSessionStatus => session != null)
431
- .map(session => hydrateSessionFromExactOptions(session, authorityEnv))
503
+ .map(session => {
504
+ const hydrated = hydrateSessionFromExactOptions(session, listed.env);
505
+ effectiveSessionEnvironments.set(hydrated, listed.env);
506
+ if (listed.env !== authorityEnv) fallbackSessionEnvironments.add(hydrated);
507
+ return hydrated;
508
+ })
432
509
  .filter((session): session is GjcTmuxSessionStatus => session?.profile === GJC_TMUX_PROFILE_VALUE)
433
- .map(session => (authority ? { ...session, providerAuthority: authority } : session));
510
+ .map(session => {
511
+ const result = authority ? { ...session, providerAuthority: authority } : session;
512
+ effectiveSessionEnvironments.set(result, listed.env);
513
+ if (listed.env !== authorityEnv) fallbackSessionEnvironments.add(result);
514
+ return result;
515
+ });
434
516
  });
435
517
  const names = new Set<string>();
436
518
  for (const session of discovered) {
@@ -443,12 +525,18 @@ export function listGjcTmuxSessions(env: NodeJS.ProcessEnv = process.env): GjcTm
443
525
  /** @internal */
444
526
  export function listTmuxSessionsForGc(env: NodeJS.ProcessEnv = process.env): GjcTmuxSessionsForGc {
445
527
  const authorityEnvironments = psmuxAuthorityEnvironments(env);
446
- const sessions = authorityEnvironments.flatMap(authorityEnv =>
447
- listSessionLines(authorityEnv)
528
+ const sessions = authorityEnvironments.flatMap(authorityEnv => {
529
+ const listed = listSessionLines(authorityEnv);
530
+ return listed.lines
448
531
  .map(parseSessionLine)
449
532
  .filter((session): session is GjcTmuxSessionStatus => session != null)
450
- .map(session => hydrateSessionFromExactOptions(session, authorityEnv)),
451
- );
533
+ .map(session => {
534
+ const hydrated = hydrateSessionFromExactOptions(session, listed.env);
535
+ effectiveSessionEnvironments.set(hydrated, listed.env);
536
+ if (listed.env !== authorityEnv) fallbackSessionEnvironments.add(hydrated);
537
+ return hydrated;
538
+ });
539
+ });
452
540
  const tagged = sessions
453
541
  .filter(session => session.profile === GJC_TMUX_PROFILE_VALUE)
454
542
  .sort((a, b) => a.name.localeCompare(b.name));
@@ -1148,17 +1236,18 @@ export function readTmuxSessionTagsForGc(
1148
1236
  env: NodeJS.ProcessEnv = process.env,
1149
1237
  ): GjcTmuxSessionTagsForGc {
1150
1238
  const session = listGjcTmuxSessions(env).find(candidate => candidate.name === sessionName);
1239
+ const sessionEnv = session ? (effectiveSessionEnvironments.get(session) ?? env) : env;
1151
1240
  return {
1152
- profile: readExactOptionForGc(sessionName, GJC_TMUX_PROFILE_OPTION, env),
1153
- project: readExactOptionForGc(sessionName, GJC_TMUX_PROJECT_OPTION, env),
1154
- psmuxIncarnation: readExactOptionForGc(sessionName, GJC_TMUX_PSMUX_INCARNATION_OPTION, env),
1155
- branch: readExactOptionForGc(sessionName, GJC_TMUX_BRANCH_OPTION, env),
1156
- branchSlug: readExactOptionForGc(sessionName, GJC_TMUX_BRANCH_SLUG_OPTION, env),
1157
- sessionId: readExactOptionForGc(sessionName, GJC_TMUX_SESSION_ID_OPTION, env),
1158
- sessionStateFile: readExactOptionForGc(sessionName, GJC_TMUX_SESSION_STATE_FILE_OPTION, env),
1159
- version: readExactOptionForGc(sessionName, GJC_TMUX_VERSION_OPTION, env),
1160
- ownerGeneration: readExactOptionForGc(sessionName, GJC_TMUX_OWNER_GENERATION_OPTION, env),
1161
- nativeSessionId: session?.nativeSessionId ?? readNativeTmuxSessionId(sessionName, env),
1241
+ profile: readExactOptionForGc(sessionName, GJC_TMUX_PROFILE_OPTION, sessionEnv),
1242
+ project: readExactOptionForGc(sessionName, GJC_TMUX_PROJECT_OPTION, sessionEnv),
1243
+ psmuxIncarnation: readExactOptionForGc(sessionName, GJC_TMUX_PSMUX_INCARNATION_OPTION, sessionEnv),
1244
+ branch: readExactOptionForGc(sessionName, GJC_TMUX_BRANCH_OPTION, sessionEnv),
1245
+ branchSlug: readExactOptionForGc(sessionName, GJC_TMUX_BRANCH_SLUG_OPTION, sessionEnv),
1246
+ sessionId: readExactOptionForGc(sessionName, GJC_TMUX_SESSION_ID_OPTION, sessionEnv),
1247
+ sessionStateFile: readExactOptionForGc(sessionName, GJC_TMUX_SESSION_STATE_FILE_OPTION, sessionEnv),
1248
+ version: readExactOptionForGc(sessionName, GJC_TMUX_VERSION_OPTION, sessionEnv),
1249
+ ownerGeneration: readExactOptionForGc(sessionName, GJC_TMUX_OWNER_GENERATION_OPTION, sessionEnv),
1250
+ nativeSessionId: session?.nativeSessionId ?? readNativeTmuxSessionId(sessionName, sessionEnv),
1162
1251
  createdAt: session?.createdAt,
1163
1252
  attached: session?.attached,
1164
1253
  panePids: session?.panePids,
@@ -1171,6 +1260,9 @@ export function removeGjcTmuxSession(
1171
1260
  expectedIdentity?: ExpectedGjcTmuxSessionIdentity,
1172
1261
  ): GjcTmuxSessionStatus {
1173
1262
  const session = statusGjcTmuxSession(sessionName, env);
1263
+ assertMutationAuthority(session);
1264
+ const sessionEnv =
1265
+ effectiveSessionEnvironments.get(session) ?? environmentForProviderAuthority(env, session.providerAuthority);
1174
1266
  if (session.attached || session.panePids.length > 0) {
1175
1267
  throw new Error(`gjc_tmux_session_live:${sessionName}`);
1176
1268
  }
@@ -1185,32 +1277,33 @@ export function removeGjcTmuxSession(
1185
1277
  session.createdAt !== expectedIdentity.createdAt)
1186
1278
  )
1187
1279
  throw new Error(`gjc_tmux_owner_changed:${sessionName}`);
1188
- if (readProfileForExactTarget(session.name, env) !== GJC_TMUX_PROFILE_VALUE) {
1280
+ if (readProfileForExactTarget(session.name, sessionEnv) !== GJC_TMUX_PROFILE_VALUE) {
1189
1281
  throw new Error(`gjc_tmux_session_not_managed:${sessionName}`);
1190
1282
  }
1191
- const nativeSessionId = readNativeTmuxSessionId(session.name, env);
1283
+ const nativeSessionId = readNativeTmuxSessionId(session.name, sessionEnv);
1192
1284
  if (!nativeSessionId) throw new Error(`gjc_tmux_owner_unverifiable:${sessionName}`);
1193
1285
  if (expectedIdentity && nativeSessionId !== expectedIdentity.nativeSessionId)
1194
1286
  throw new Error(`gjc_tmux_owner_changed:${sessionName}`);
1195
1287
  if (
1196
1288
  expectedIdentity &&
1197
- readExactOptionForGc(session.name, GJC_TMUX_OWNER_GENERATION_OPTION, env) !== expectedIdentity.ownerGeneration
1289
+ readExactOptionForGc(session.name, GJC_TMUX_OWNER_GENERATION_OPTION, sessionEnv) !==
1290
+ expectedIdentity.ownerGeneration
1198
1291
  )
1199
1292
  throw new Error(`gjc_tmux_owner_changed:${sessionName}`);
1200
- const firstServer = requireSafeTmuxServerForMutation(resolveGjcTmuxCommand(env), env);
1293
+ const firstServer = requireSafeTmuxServerForMutation(resolveGjcTmuxCommand(sessionEnv), sessionEnv);
1201
1294
  if (
1202
- readNativeTmuxSessionId(nativeSessionId, env) !== nativeSessionId ||
1203
- readProfileForExactTarget(nativeSessionId, env) !== GJC_TMUX_PROFILE_VALUE
1295
+ readNativeTmuxSessionId(nativeSessionId, sessionEnv) !== nativeSessionId ||
1296
+ readProfileForExactTarget(nativeSessionId, sessionEnv) !== GJC_TMUX_PROFILE_VALUE
1204
1297
  )
1205
1298
  throw new Error(`gjc_tmux_owner_changed:${sessionName}`);
1206
- const finalServer = requireSafeTmuxServerForMutation(resolveGjcTmuxCommand(env), env);
1299
+ const finalServer = requireSafeTmuxServerForMutation(resolveGjcTmuxCommand(sessionEnv), sessionEnv);
1207
1300
  if (finalServer.pid !== firstServer.pid || finalServer.startTime !== firstServer.startTime)
1208
1301
  throw new Error(`gjc_tmux_owner_changed:${sessionName}`);
1209
1302
  runGuardedTmuxSessionCommand(
1210
1303
  nativeSessionId,
1211
1304
  session.name,
1212
1305
  finalServer,
1213
- env,
1306
+ sessionEnv,
1214
1307
  `kill-session -t '${nativeSessionId}'`,
1215
1308
  expectedIdentity?.ownerGeneration,
1216
1309
  );
@@ -1426,7 +1519,9 @@ export async function forceCloseGjcTmuxSession(
1426
1519
  deps: Partial<ForceCloseOwnerDependencies> = {},
1427
1520
  ): Promise<GjcTmuxSessionStatus> {
1428
1521
  const session = statusGjcTmuxSession(sessionName, env);
1429
- const sessionEnv = environmentForProviderAuthority(env, session.providerAuthority);
1522
+ assertMutationAuthority(session);
1523
+ const sessionEnv =
1524
+ effectiveSessionEnvironments.get(session) ?? environmentForProviderAuthority(env, session.providerAuthority);
1430
1525
  if (readProfileForExactTarget(session.name, sessionEnv) !== GJC_TMUX_PROFILE_VALUE)
1431
1526
  throw new Error(`gjc_tmux_session_not_managed:${sessionName}`);
1432
1527
  const exactPanePids = (deps.listPanePids ?? readExactSessionPanePids)(session.name, sessionEnv);
@@ -1560,7 +1655,9 @@ export async function forceCloseGjcTmuxSession(
1560
1655
 
1561
1656
  export function attachGjcTmuxSession(sessionName: string, env: NodeJS.ProcessEnv = process.env): never {
1562
1657
  const session = statusGjcTmuxSession(sessionName, env);
1563
- const sessionEnv = environmentForProviderAuthority(env, session.providerAuthority);
1658
+ assertMutationAuthority(session);
1659
+ const sessionEnv =
1660
+ effectiveSessionEnvironments.get(session) ?? environmentForProviderAuthority(env, session.providerAuthority);
1564
1661
  const authority = session.providerAuthority ?? psmuxAuthorityFromEnv(sessionEnv);
1565
1662
  const tmuxCommand = authority?.command ?? resolveGjcTmuxCommand(sessionEnv);
1566
1663
  if (authority) assertGjcTmuxMutationAuthoritySync(authority);