@gajae-code/coding-agent 0.9.0 → 0.9.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.
Files changed (98) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/types/cli/args.d.ts +1 -0
  3. package/dist/types/cli/notify-cli.d.ts +5 -0
  4. package/dist/types/cli.d.ts +3 -0
  5. package/dist/types/commands/launch.d.ts +3 -0
  6. package/dist/types/config/model-profile-activation.d.ts +7 -7
  7. package/dist/types/config/model-registry.d.ts +7 -3
  8. package/dist/types/config/model-resolver.d.ts +2 -2
  9. package/dist/types/config/settings-schema.d.ts +15 -1
  10. package/dist/types/config/settings.d.ts +7 -0
  11. package/dist/types/extensibility/runtime-skill-discovery.d.ts +20 -0
  12. package/dist/types/gjc-runtime/team-runtime.d.ts +9 -0
  13. package/dist/types/gjc-runtime/tmux-common.d.ts +1 -0
  14. package/dist/types/modes/components/settings-selector.d.ts +2 -2
  15. package/dist/types/modes/components/thinking-selector.d.ts +7 -2
  16. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  17. package/dist/types/modes/interactive-mode.d.ts +2 -0
  18. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -3
  19. package/dist/types/modes/rpc/rpc-socket-security.d.ts +3 -0
  20. package/dist/types/modes/types.d.ts +3 -0
  21. package/dist/types/modes/utils/injected-user-submission.d.ts +52 -0
  22. package/dist/types/notifications/config-commands.d.ts +38 -0
  23. package/dist/types/notifications/config.d.ts +16 -0
  24. package/dist/types/notifications/index.d.ts +14 -1
  25. package/dist/types/notifications/lifecycle-commands.d.ts +1 -0
  26. package/dist/types/notifications/lifecycle-control-runtime.d.ts +2 -1
  27. package/dist/types/notifications/reply-sent-store.d.ts +53 -0
  28. package/dist/types/notifications/rich-draft.d.ts +68 -0
  29. package/dist/types/notifications/rich-render.d.ts +90 -0
  30. package/dist/types/notifications/telegram-daemon-cli.d.ts +3 -3
  31. package/dist/types/notifications/telegram-daemon.d.ts +22 -0
  32. package/dist/types/notifications/telegram-reference.d.ts +7 -0
  33. package/dist/types/notifications/threaded-render.d.ts +10 -0
  34. package/dist/types/notifications/topic-registry.d.ts +4 -5
  35. package/dist/types/runtime-credential-selector.d.ts +7 -0
  36. package/dist/types/sdk.d.ts +7 -1
  37. package/dist/types/session/agent-session.d.ts +6 -3
  38. package/dist/types/tools/index.d.ts +1 -0
  39. package/dist/types/tools/skill-discovery.d.ts +40 -0
  40. package/dist/types/utils/pasted-image-path.d.ts +30 -0
  41. package/package.json +8 -11
  42. package/src/cli/args.ts +8 -0
  43. package/src/cli/notify-cli.ts +38 -10
  44. package/src/cli.ts +5 -0
  45. package/src/commands/launch.ts +5 -0
  46. package/src/commands/notify.ts +2 -2
  47. package/src/config/keybindings.ts +1 -1
  48. package/src/config/model-profile-activation.ts +23 -7
  49. package/src/config/model-registry.ts +21 -4
  50. package/src/config/model-resolver.ts +5 -16
  51. package/src/config/settings-schema.ts +11 -2
  52. package/src/config/settings.ts +24 -1
  53. package/src/extensibility/runtime-skill-discovery.ts +229 -0
  54. package/src/gjc-runtime/launch-tmux.ts +78 -12
  55. package/src/gjc-runtime/psmux-detect.ts +17 -2
  56. package/src/gjc-runtime/team-runtime.ts +46 -7
  57. package/src/gjc-runtime/tmux-common.ts +1 -0
  58. package/src/gjc-runtime/ultragoal-runtime.ts +10 -1
  59. package/src/internal-urls/docs-index.generated.ts +6 -6
  60. package/src/lsp/client.ts +1 -0
  61. package/src/main.ts +26 -3
  62. package/src/modes/components/footer.ts +7 -2
  63. package/src/modes/components/settings-selector.ts +3 -3
  64. package/src/modes/components/thinking-selector.ts +83 -20
  65. package/src/modes/controllers/event-controller.ts +11 -2
  66. package/src/modes/controllers/extension-ui-controller.ts +8 -1
  67. package/src/modes/controllers/input-controller.ts +62 -26
  68. package/src/modes/controllers/selector-controller.ts +43 -0
  69. package/src/modes/interactive-mode.ts +5 -0
  70. package/src/modes/rpc/rpc-mode.ts +3 -9
  71. package/src/modes/rpc/rpc-socket-security.ts +13 -1
  72. package/src/modes/types.ts +3 -0
  73. package/src/modes/utils/injected-user-submission.ts +94 -0
  74. package/src/modes/utils/ui-helpers.ts +48 -15
  75. package/src/notifications/config-commands.ts +90 -0
  76. package/src/notifications/config.ts +27 -0
  77. package/src/notifications/index.ts +184 -14
  78. package/src/notifications/lifecycle-commands.ts +37 -16
  79. package/src/notifications/lifecycle-control-runtime.ts +8 -3
  80. package/src/notifications/reply-sent-store.ts +134 -0
  81. package/src/notifications/rich-draft.ts +107 -0
  82. package/src/notifications/rich-render.ts +143 -0
  83. package/src/notifications/telegram-daemon-cli.ts +60 -5
  84. package/src/notifications/telegram-daemon.ts +431 -91
  85. package/src/notifications/telegram-reference.ts +17 -0
  86. package/src/notifications/threaded-render.ts +42 -1
  87. package/src/notifications/topic-registry.ts +9 -8
  88. package/src/prompts/tools/search-tool-bm25.md +5 -0
  89. package/src/prompts/tools/skill-discovery.md +13 -0
  90. package/src/runtime-credential-selector.ts +35 -0
  91. package/src/sdk.ts +104 -13
  92. package/src/session/agent-session.ts +48 -22
  93. package/src/slash-commands/builtin-registry.ts +78 -1
  94. package/src/system-prompt.ts +4 -6
  95. package/src/tools/index.ts +4 -0
  96. package/src/tools/skill-discovery.ts +73 -0
  97. package/src/tools/skill.ts +15 -2
  98. package/src/utils/pasted-image-path.ts +170 -0
@@ -0,0 +1,229 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { findRepoRoot } from "../capability/fs";
5
+ import type { Skill as CapabilitySkill } from "../capability/skill";
6
+ import type { SkillsSettings } from "../config/settings-schema";
7
+ import { compareSkillOrder, scanSkillsFromDir } from "../discovery/helpers";
8
+ import type { Skill } from "./skills";
9
+
10
+ export type RuntimeSkillDiscoverySource = "project" | "user";
11
+
12
+ export interface RuntimeSkillDiscoveryCandidate {
13
+ name: string;
14
+ description: string;
15
+ source: RuntimeSkillDiscoverySource;
16
+ path: string;
17
+ useWhen?: string[];
18
+ }
19
+
20
+ export interface DiscoverRuntimeSkillsOptions {
21
+ cwd: string;
22
+ home?: string;
23
+ query?: string;
24
+ limit?: number;
25
+ source?: RuntimeSkillDiscoverySource | "all";
26
+ policy?: SkillsSettings;
27
+ }
28
+
29
+ function getRuntimeHome(): string {
30
+ return process.env.HOME || os.homedir();
31
+ }
32
+
33
+ const DEFAULT_LIMIT = 20;
34
+ const MAX_LIMIT = 50;
35
+
36
+ function normalizeLimit(limit: number | undefined): number {
37
+ if (limit === undefined || !Number.isFinite(limit)) return DEFAULT_LIMIT;
38
+ return Math.max(1, Math.min(MAX_LIMIT, Math.trunc(limit)));
39
+ }
40
+
41
+ async function getProjectSkillDirs(cwd: string, home: string): Promise<{ dirs: string[]; repoRoot: string | null }> {
42
+ const dirs: string[] = [];
43
+ let current = path.resolve(cwd);
44
+ const resolvedHome = path.resolve(home);
45
+ const repoRoot = await findRepoRoot(current);
46
+ const stop = path.resolve(repoRoot ?? current);
47
+ while (true) {
48
+ if (current !== resolvedHome) {
49
+ dirs.push(path.join(current, ".gjc", "skills"));
50
+ }
51
+ if (current === stop) break;
52
+ const parent = path.dirname(current);
53
+ if (parent === current) break;
54
+ current = parent;
55
+ }
56
+ return { dirs, repoRoot };
57
+ }
58
+
59
+ function getUseWhen(skill: CapabilitySkill): string[] | undefined {
60
+ const frontmatter = skill.frontmatter as Record<string, unknown> | undefined;
61
+ const values: string[] = [];
62
+ const globs = frontmatter?.globs;
63
+ if (Array.isArray(globs)) {
64
+ values.push(...globs.filter((value): value is string => typeof value === "string"));
65
+ } else if (typeof globs === "string") {
66
+ values.push(globs);
67
+ }
68
+ for (const key of ["use_when", "useWhen", "conditions"]) {
69
+ const raw = frontmatter?.[key];
70
+ if (typeof raw === "string") values.push(raw);
71
+ if (Array.isArray(raw)) values.push(...raw.filter((value): value is string => typeof value === "string"));
72
+ }
73
+ return values.length > 0 ? values : undefined;
74
+ }
75
+
76
+ function toRuntimeSkill(skill: CapabilitySkill, source: RuntimeSkillDiscoverySource): Skill {
77
+ return {
78
+ name: skill.name,
79
+ description: typeof skill.frontmatter?.description === "string" ? skill.frontmatter.description : "",
80
+ filePath: skill.path,
81
+ baseDir: skill.path.replace(/[\\/]SKILL\.md$/, ""),
82
+ source: `runtime:${source}`,
83
+ hide: skill.frontmatter?.hide === true,
84
+ _source: { ...skill._source, providerName: "Runtime skill discovery" },
85
+ };
86
+ }
87
+ function sourceEnabled(source: RuntimeSkillDiscoverySource, policy: SkillsSettings | undefined): boolean {
88
+ if (policy?.enabled !== true) return false;
89
+ if (source === "project") return policy.enablePiProject === true;
90
+ if (source === "user") return policy.enablePiUser === true;
91
+ return false;
92
+ }
93
+
94
+ function matchesIncludePatterns(name: string, includeSkills: string[] | undefined): boolean {
95
+ if (!includeSkills || includeSkills.length === 0) return true;
96
+ return includeSkills.some(pattern => new Bun.Glob(pattern).match(name));
97
+ }
98
+
99
+ function matchesIgnorePatterns(name: string, ignoredSkills: string[] | undefined): boolean {
100
+ if (!ignoredSkills || ignoredSkills.length === 0) return false;
101
+ return ignoredSkills.some(pattern => new Bun.Glob(pattern).match(name));
102
+ }
103
+
104
+ function isDisabledSkill(name: string, disabledExtensions: string[] | undefined): boolean {
105
+ return (disabledExtensions ?? []).some(id => id === `skill:${name}`);
106
+ }
107
+
108
+ function isAllowedByPolicy(
109
+ skill: CapabilitySkill,
110
+ source: RuntimeSkillDiscoverySource,
111
+ policy: SkillsSettings | undefined,
112
+ ): boolean {
113
+ if (!sourceEnabled(source, policy)) return false;
114
+ if (isDisabledSkill(skill.name, policy?.disabledExtensions)) return false;
115
+ if (matchesIgnorePatterns(skill.name, policy?.ignoredSkills)) return false;
116
+ if (!matchesIncludePatterns(skill.name, policy?.includeSkills)) return false;
117
+ return true;
118
+ }
119
+ function matchesQuery(candidate: RuntimeSkillDiscoveryCandidate, query: string): boolean {
120
+ const normalized = query.trim().toLowerCase();
121
+ if (!normalized) return true;
122
+ const haystack = [candidate.name, candidate.description, candidate.source, ...(candidate.useWhen ?? [])]
123
+ .join("\n")
124
+ .toLowerCase();
125
+ return normalized
126
+ .split(/\s+/)
127
+ .filter(Boolean)
128
+ .every(term => haystack.includes(term));
129
+ }
130
+
131
+ async function realPathOrSelf(filePath: string): Promise<string> {
132
+ try {
133
+ return await fs.realpath(filePath);
134
+ } catch {
135
+ return filePath;
136
+ }
137
+ }
138
+
139
+ export async function discoverRuntimeSkills(
140
+ options: DiscoverRuntimeSkillsOptions,
141
+ ): Promise<RuntimeSkillDiscoveryCandidate[]> {
142
+ const home = options.home ?? getRuntimeHome();
143
+ const source = options.source ?? "all";
144
+ const policy = options.policy;
145
+ const scanJobs: Array<Promise<{ skill: CapabilitySkill; source: RuntimeSkillDiscoverySource }[]>> = [];
146
+ const projectSkills = await getProjectSkillDirs(options.cwd, home);
147
+ const projectContext = { cwd: options.cwd, home, repoRoot: projectSkills.repoRoot };
148
+ if ((source === "all" || source === "project") && sourceEnabled("project", policy)) {
149
+ for (const dir of projectSkills.dirs) {
150
+ scanJobs.push(
151
+ scanSkillsFromDir(projectContext, {
152
+ dir,
153
+ providerId: "runtime",
154
+ level: "project",
155
+ requireDescription: true,
156
+ }).then(result => result.items.map(skill => ({ skill, source: "project" as const }))),
157
+ );
158
+ }
159
+ }
160
+ if ((source === "all" || source === "user") && sourceEnabled("user", policy)) {
161
+ scanJobs.push(
162
+ scanSkillsFromDir(
163
+ { cwd: options.cwd, home, repoRoot: home },
164
+ { dir: path.join(home, ".gjc", "skills"), providerId: "runtime", level: "user", requireDescription: true },
165
+ ).then(result => result.items.map(skill => ({ skill, source: "user" as const }))),
166
+ );
167
+ }
168
+
169
+ const seenNames = new Set<string>();
170
+ const seenPaths = new Set<string>();
171
+ const candidates: RuntimeSkillDiscoveryCandidate[] = [];
172
+ for (const entry of (await Promise.all(scanJobs)).flat()) {
173
+ if (!isAllowedByPolicy(entry.skill, entry.source, policy)) continue;
174
+ const realPath = await realPathOrSelf(entry.skill.path);
175
+ if (seenPaths.has(realPath) || seenNames.has(entry.skill.name)) continue;
176
+ seenPaths.add(realPath);
177
+ seenNames.add(entry.skill.name);
178
+ const candidate: RuntimeSkillDiscoveryCandidate = {
179
+ name: entry.skill.name,
180
+ description:
181
+ typeof entry.skill.frontmatter?.description === "string" ? entry.skill.frontmatter.description : "",
182
+ source: entry.source,
183
+ path: entry.skill.path,
184
+ useWhen: getUseWhen(entry.skill),
185
+ };
186
+ if (matchesQuery(candidate, options.query ?? "")) candidates.push(candidate);
187
+ }
188
+ candidates.sort((a, b) => compareSkillOrder(a.name, a.path, b.name, b.path));
189
+ return candidates.slice(0, normalizeLimit(options.limit));
190
+ }
191
+
192
+ export async function findRuntimeSkillByName(
193
+ cwd: string,
194
+ name: string,
195
+ policy?: SkillsSettings,
196
+ home = getRuntimeHome(),
197
+ ): Promise<Skill | undefined> {
198
+ const normalized = name.trim();
199
+ if (!normalized) return undefined;
200
+ const scanJobs: Array<Promise<{ skill: CapabilitySkill; source: RuntimeSkillDiscoverySource }[]>> = [];
201
+ const projectSkills = await getProjectSkillDirs(cwd, home);
202
+ const projectContext = { cwd, home, repoRoot: projectSkills.repoRoot };
203
+ if (sourceEnabled("project", policy)) {
204
+ scanJobs.push(
205
+ ...projectSkills.dirs.map(dir =>
206
+ scanSkillsFromDir(projectContext, {
207
+ dir,
208
+ providerId: "runtime",
209
+ level: "project",
210
+ requireDescription: true,
211
+ }).then(result => result.items.map(skill => ({ skill, source: "project" as const }))),
212
+ ),
213
+ );
214
+ }
215
+ if (sourceEnabled("user", policy)) {
216
+ scanJobs.push(
217
+ scanSkillsFromDir(
218
+ { cwd, home, repoRoot: home },
219
+ { dir: path.join(home, ".gjc", "skills"), providerId: "runtime", level: "user", requireDescription: true },
220
+ ).then(result => result.items.map(skill => ({ skill, source: "user" as const }))),
221
+ );
222
+ }
223
+ for (const entry of (await Promise.all(scanJobs)).flat()) {
224
+ if (entry.skill.name === normalized && isAllowedByPolicy(entry.skill, entry.source, policy)) {
225
+ return toRuntimeSkill(entry.skill, entry.source);
226
+ }
227
+ }
228
+ return undefined;
229
+ }
@@ -13,6 +13,7 @@ import {
13
13
  buildGjcTmuxSessionName,
14
14
  buildGjcTmuxSessionSlug,
15
15
  GJC_DEFAULT_TMUX_SESSION,
16
+ GJC_TMUX_ACTIVE_SESSION_ENV,
16
17
  GJC_TMUX_COMMAND_ENV,
17
18
  GJC_TMUX_MOUSE_ENV,
18
19
  GJC_TMUX_PROFILE_ENV,
@@ -157,11 +158,43 @@ export interface GjcTmuxProfileContext {
157
158
  version?: string | null;
158
159
  }
159
160
 
161
+ function tmuxExitMarkerPath(sessionStateFile: string): string {
162
+ return path.join(path.dirname(sessionStateFile), "tmux-exit.json");
163
+ }
164
+
165
+ function buildPosixTmuxExitMarkerPrefix(markerPath: string): string {
166
+ const markerDir = path.dirname(markerPath);
167
+ return [
168
+ `__gjc_tmux_exit_marker=${shellQuote(markerPath)}`,
169
+ "__gjc_tmux_write_exit_marker() { __gjc_tmux_status=$?",
170
+ "__gjc_tmux_ended_at=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date)",
171
+ `mkdir -p ${shellQuote(markerDir)} 2>/dev/null || true`,
172
+ 'printf \'{"schema_version":1,"source":"tmux_inner_shell","ended_at":"%s","exit_code":%s}\\n\' "$__gjc_tmux_ended_at" "$__gjc_tmux_status" > "$__gjc_tmux_exit_marker" 2>/dev/null || true',
173
+ "}",
174
+ "trap __gjc_tmux_write_exit_marker EXIT",
175
+ ].join("; ");
176
+ }
177
+
178
+ function buildPowerShellTmuxExitMarkerFinally(markerPath: string): string {
179
+ const markerDir = path.win32.dirname(markerPath);
180
+ return [
181
+ "} finally {",
182
+ "\ttry {",
183
+ `\t\tNew-Item -ItemType Directory -Force -Path ${powershellQuote(markerDir)} -ErrorAction Stop | Out-Null`,
184
+ "\t\t$__gjcTmuxExitMarker = @{ schema_version = 1; source = 'tmux_inner_shell'; ended_at = (Get-Date).ToUniversalTime().ToString('o'); exit_code = $__gjcTmuxExitCode } | ConvertTo-Json -Compress",
185
+ `\t\tSet-Content -LiteralPath ${powershellQuote(markerPath)} -Value $__gjcTmuxExitMarker -Encoding UTF8 -ErrorAction Stop`,
186
+ "\t} catch {",
187
+ "\t}",
188
+ "}",
189
+ ].join("\n");
190
+ }
191
+
160
192
  interface CommandResolutionContext {
161
193
  cwd: string;
162
194
  argv: string[];
163
195
  execPath: string;
164
196
  extraEnv?: Record<string, string>;
197
+ tmuxExitMarkerPath?: string;
165
198
  platform?: NodeJS.Platform;
166
199
  }
167
200
 
@@ -285,7 +318,17 @@ function buildWindowsPowerShellInnerCommand(context: CommandResolutionContext, r
285
318
  const innerArgs = stripRootTmuxFlag(rawArgs).map(powershellQuote).join(" ");
286
319
  const invocation = `& ${resolvedCommand} ${innerArgs}`;
287
320
  const exitLine = "if ($null -ne $LASTEXITCODE) { exit $LASTEXITCODE } else { exit 1 }";
288
- const script = [...envLines, invocation, exitLine].join("\n");
321
+ const script = context.tmuxExitMarkerPath
322
+ ? [
323
+ ...envLines,
324
+ "$__gjcTmuxExitCode = 1",
325
+ "try {",
326
+ `\t${invocation}`,
327
+ "\tif ($null -ne $LASTEXITCODE) { $__gjcTmuxExitCode = $LASTEXITCODE } else { $__gjcTmuxExitCode = 1 }",
328
+ buildPowerShellTmuxExitMarkerFinally(context.tmuxExitMarkerPath),
329
+ "exit $__gjcTmuxExitCode",
330
+ ].join("\n")
331
+ : [...envLines, invocation, exitLine].join("\n");
289
332
  // Encode the script as UTF-16LE base64 for pwsh -EncodedCommand. Do NOT
290
333
  // prepend a UTF-16LE BOM (0xFF 0xFE): the BOM survives the decode and is
291
334
  // inserted as a literal U+FEFF character in front of the first script
@@ -365,7 +408,9 @@ function buildInnerCommand(context: CommandResolutionContext, rawArgs: string[])
365
408
  if (isWindowsPlatform(context.platform)) return buildWindowsPowerShellInnerCommand(context, rawArgs);
366
409
  const command = resolveCurrentGjcCommand(context);
367
410
  const quoted = [...command, ...stripRootTmuxFlag(rawArgs)].map(shellQuote).join(" ");
368
- return `exec env ${GJC_TMUX_LAUNCHED_ENV}=1${buildEnvAssignments(context.extraEnv)} ${quoted}`;
411
+ const invocation = `env ${GJC_TMUX_LAUNCHED_ENV}=1${buildEnvAssignments(context.extraEnv)} ${quoted}`;
412
+ if (!context.tmuxExitMarkerPath) return `exec ${invocation}`;
413
+ return `${buildPosixTmuxExitMarkerPrefix(context.tmuxExitMarkerPath)}; ${invocation}; exit $?`;
369
414
  }
370
415
 
371
416
  function visibleWidth(value: string): number {
@@ -403,6 +448,9 @@ function truncateVisibleTail(value: string, maxWidth: number): string {
403
448
  const GJC_TMUX_WINDOW_BRANCH_SEPARATOR = "-";
404
449
  const GJC_TMUX_WINDOW_TITLE_PREFIX = "GJC-";
405
450
  const GJC_TMUX_TERMINAL_TITLE_PREFIX = "GJC: ";
451
+ const GJC_TMUX_ROOT_TERMINAL_TITLE_OPTION = "@gjc-root-terminal-title";
452
+ const GJC_TMUX_ROOT_TERMINAL_TITLE_SESSION_OPTION = "@gjc-root-terminal-title-session";
453
+ const GJC_TMUX_DYNAMIC_SESSION_TITLE = "GJC: #{session_name}";
406
454
 
407
455
  function sanitizeTmuxWindowTitleSegment(value: string): string {
408
456
  return value.replace(/:+/g, "-");
@@ -444,17 +492,31 @@ function sanitizeGjcTmuxRootTerminalTitle(title: string): string {
444
492
  return title.replace(TERMINAL_TITLE_CONTROL_CHARS, "").trim() || "GJC";
445
493
  }
446
494
 
447
- function escapeTmuxFormatLiteral(value: string): string {
448
- return value.replace(/#/g, "##");
495
+ function buildGjcTmuxRootTerminalTitleFormat(sessionName: string): string {
496
+ if (!sessionName.startsWith(GJC_TMUX_SESSION_PREFIX)) return GJC_TMUX_DYNAMIC_SESSION_TITLE;
497
+ return `#{?#{==:#{${GJC_TMUX_ROOT_TERMINAL_TITLE_SESSION_OPTION}},#{session_name}},#{${GJC_TMUX_ROOT_TERMINAL_TITLE_OPTION}},${GJC_TMUX_DYNAMIC_SESSION_TITLE}}`;
449
498
  }
450
499
 
451
- function buildGjcTmuxRootTerminalTitleCommands(target: string, title: string): GjcTmuxProfileCommand[] {
452
- const sanitized = escapeTmuxFormatLiteral(sanitizeGjcTmuxRootTerminalTitle(title));
500
+ function buildGjcTmuxRootTerminalTitleCommands(
501
+ target: string,
502
+ sessionName: string,
503
+ title: string,
504
+ ): GjcTmuxProfileCommand[] {
505
+ const sanitized = sanitizeGjcTmuxRootTerminalTitle(title);
506
+ const format = buildGjcTmuxRootTerminalTitleFormat(sessionName);
453
507
  return [
508
+ {
509
+ description: "remember tmux client terminal title fallback",
510
+ args: ["set-option", "-t", target, GJC_TMUX_ROOT_TERMINAL_TITLE_OPTION, sanitized],
511
+ },
512
+ {
513
+ description: "remember tmux client terminal title session",
514
+ args: ["set-option", "-t", target, GJC_TMUX_ROOT_TERMINAL_TITLE_SESSION_OPTION, sessionName],
515
+ },
454
516
  { description: "enable tmux client terminal title", args: ["set-option", "-t", target, "set-titles", "on"] },
455
517
  {
456
- description: "set tmux client terminal title",
457
- args: ["set-option", "-t", target, "set-titles-string", sanitized],
518
+ description: "set dynamic tmux client terminal title",
519
+ args: ["set-option", "-t", target, "set-titles-string", format],
458
520
  },
459
521
  ];
460
522
  }
@@ -467,10 +529,8 @@ function applyGjcTmuxRootTerminalTitleProfile(context: {
467
529
  options: TmuxSpawnOptions;
468
530
  }): void {
469
531
  if (!context.title) return;
470
- for (const command of buildGjcTmuxRootTerminalTitleCommands(
471
- buildGjcTmuxExactOptionTarget(context.target, { env: context.options.env }),
472
- context.title,
473
- )) {
532
+ const optionTarget = buildGjcTmuxExactOptionTarget(context.target, { env: context.options.env });
533
+ for (const command of buildGjcTmuxRootTerminalTitleCommands(optionTarget, context.target, context.title)) {
474
534
  context.spawnSync(context.tmuxCommand, command.args, context.options);
475
535
  }
476
536
  }
@@ -688,7 +748,13 @@ export function buildDefaultTmuxLaunchPlan(context: TmuxLaunchContext): TmuxLaun
688
748
  extraEnv: {
689
749
  [GJC_COORDINATOR_SESSION_ID_ENV]: sessionId,
690
750
  [GJC_COORDINATOR_SESSION_STATE_FILE_ENV]: sessionStateFile,
751
+ // Carry the GJC-managed session name into the child so `gjc team`
752
+ // can target the correct leader session by name. Under psmux on
753
+ // Windows the inherited TMUX_PANE can resolve to the wrong/default
754
+ // session, which would split/send workers into the wrong session.
755
+ [GJC_TMUX_ACTIVE_SESSION_ENV]: sessionName,
691
756
  },
757
+ tmuxExitMarkerPath: tmuxExitMarkerPath(sessionStateFile),
692
758
  platform,
693
759
  },
694
760
  context.rawArgs,
@@ -115,6 +115,20 @@ function outputMentionsPsmux(output: string): boolean {
115
115
  return PSMUX_VERSION_MARKERS.some(marker => output.includes(marker));
116
116
  }
117
117
 
118
+ function normalizedCommandBaseName(command: string): string {
119
+ const normalized = command
120
+ .trim()
121
+ .replace(/^['"]|['"]$/g, "")
122
+ .replace(/\\/g, "/");
123
+ const basename = normalized.slice(normalized.lastIndexOf("/") + 1).toLowerCase();
124
+ return basename.endsWith(".exe") ? basename.slice(0, -4) : basename;
125
+ }
126
+
127
+ function isNamedPsmuxCommand(command: string): boolean {
128
+ const basename = normalizedCommandBaseName(command);
129
+ return basename === "psmux" || basename === "pmux";
130
+ }
131
+
118
132
  function resolveBinaryPath(candidate: string): string | null {
119
133
  return activeBinaryResolver(candidate);
120
134
  }
@@ -194,13 +208,14 @@ export function resolveGjcTmuxBinary(options: ResolveGjcTmuxBinaryOptions = {}):
194
208
  const runner = options.runner ?? readSpawnRunner();
195
209
  const explicit = env.GJC_TMUX_COMMAND?.trim() || env.GJC_TEAM_TMUX_COMMAND?.trim();
196
210
  if (explicit) {
197
- const isPsmux = detectPsmux(explicit, { env, runner });
211
+ const isPsmux =
212
+ platform === "win32" && isNamedPsmuxCommand(explicit) ? true : detectPsmux(explicit, { env, runner });
198
213
  return { command: explicit, isPsmux, viaExplicitOverride: true };
199
214
  }
200
215
  if (platform === "win32") {
201
216
  for (const candidate of PSMUX_BINARY_NAMES) {
202
217
  if (resolveBinaryPath(candidate)) {
203
- const isPsmux = detectPsmux(candidate, { env, runner });
218
+ const isPsmux = isNamedPsmuxCommand(candidate) ? true : detectPsmux(candidate, { env, runner });
204
219
  return { command: candidate, isPsmux, viaExplicitOverride: false };
205
220
  }
206
221
  }
@@ -1,6 +1,7 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import * as fs from "node:fs/promises";
3
3
  import * as path from "node:path";
4
+ import { getWorktreesDir } from "@gajae-code/utils/dirs";
4
5
  import type { WorkflowHudSummary } from "../skill-state/active-state";
5
6
  import { buildTeamHudSummary as buildWorkflowTeamHudSummary } from "../skill-state/workflow-hud";
6
7
  import { WORKFLOW_STATE_VERSION } from "../skill-state/workflow-state-contract";
@@ -22,8 +23,10 @@ import {
22
23
  import {
23
24
  buildGjcTmuxExactOptionTarget,
24
25
  buildGjcTmuxUntaggedSessionHint,
26
+ GJC_TMUX_ACTIVE_SESSION_ENV,
25
27
  GJC_TMUX_PROFILE_OPTION,
26
28
  GJC_TMUX_PROFILE_VALUE,
29
+ resolveGjcTmuxBinary,
27
30
  resolveGjcTmuxCommand,
28
31
  } from "./tmux-common";
29
32
 
@@ -287,6 +290,7 @@ export interface GjcTeamStartOptions {
287
290
  cwd?: string;
288
291
  env?: NodeJS.ProcessEnv;
289
292
  dryRun?: boolean;
293
+ platform?: NodeJS.Platform;
290
294
  mailboxDeliveryTransport?: GjcTeamMailboxDeliveryTransport;
291
295
  }
292
296
 
@@ -1843,18 +1847,41 @@ function findWorktreePath(repoRoot: string, worktreePath: string): string | null
1843
1847
  if (line.startsWith("worktree ") && path.resolve(line.slice("worktree ".length)) === resolved) return resolved;
1844
1848
  return null;
1845
1849
  }
1850
+ export function resolveWorkerWorktreePath(input: {
1851
+ repoRoot: string;
1852
+ stateDir: string;
1853
+ teamName: string;
1854
+ workerId: string;
1855
+ platform: NodeJS.Platform;
1856
+ isPsmux: boolean;
1857
+ }): string {
1858
+ if (input.platform === "win32" && input.isPsmux) {
1859
+ const slug = stableHash([input.repoRoot, input.stateDir, input.teamName].join("\0")).slice(0, 12);
1860
+ return path.join(getWorktreesDir(), `team-${slug}-${input.workerId}`);
1861
+ }
1862
+ return path.join(input.stateDir, "worktrees", input.workerId);
1863
+ }
1846
1864
  async function ensureWorkerWorktree(
1847
1865
  cwd: string,
1848
1866
  dir: string,
1849
1867
  teamName: string,
1850
1868
  worker: GjcTeamWorker,
1851
1869
  mode: GjcTeamWorktreeMode,
1870
+ platform: NodeJS.Platform,
1871
+ isPsmux: boolean,
1852
1872
  ): Promise<GjcTeamWorker> {
1853
1873
  if (!mode.enabled) return worker;
1854
1874
  if (!isGitRepository(cwd)) throw new Error(`team_worktree_requires_git_repo:${cwd}`);
1855
1875
  const repoRoot = runGit(cwd, ["rev-parse", "--show-toplevel"]);
1856
1876
  const baseRef = runGit(repoRoot, ["rev-parse", "HEAD"]);
1857
- const worktreePath = path.join(dir, "worktrees", worker.id);
1877
+ const worktreePath = resolveWorkerWorktreePath({
1878
+ repoRoot,
1879
+ stateDir: dir,
1880
+ teamName,
1881
+ workerId: worker.id,
1882
+ platform,
1883
+ isPsmux,
1884
+ });
1858
1885
  const existing = findWorktreePath(repoRoot, worktreePath);
1859
1886
  let created = false;
1860
1887
  const branchName = mode.detached
@@ -1922,16 +1949,22 @@ function tagTmuxSessionAsGjcLeader(tmuxCommand: string, sessionName: string): bo
1922
1949
  function readCurrentTmuxLeaderContext(tmuxCommand: string, env: NodeJS.ProcessEnv): GjcTmuxLeaderContext {
1923
1950
  if (Bun.which(tmuxCommand) === null)
1924
1951
  throw new Error(buildTeamTmuxLeaderRequirementMessage(`tmux_not_installed:${tmuxCommand}`));
1925
- const paneTarget = env.TMUX_PANE?.trim();
1926
- const args = paneTarget
1927
- ? ["display-message", "-p", "-t", paneTarget, "#S:#I #{pane_id}"]
1952
+ // Prefer the explicit GJC-managed session name propagated by `gjc --tmux`
1953
+ // (GJC_TMUX_ACTIVE_SESSION). Under psmux on Windows the inherited TMUX_PANE
1954
+ // can resolve to the wrong/default session, so querying the tagged session
1955
+ // by name is authoritative for GJC-launched leaders. Fall back to TMUX_PANE,
1956
+ // then to the ambient session, to keep native tmux/WSL flows unchanged.
1957
+ const activeSession = env[GJC_TMUX_ACTIVE_SESSION_ENV]?.trim();
1958
+ const displayTarget = activeSession ? buildGjcTmuxExactOptionTarget(activeSession, { env }) : env.TMUX_PANE?.trim();
1959
+ const args = displayTarget
1960
+ ? ["display-message", "-p", "-t", displayTarget, "#S:#I #{pane_id}"]
1928
1961
  : ["display-message", "-p", "#S:#I #{pane_id}"];
1929
1962
  const result = Bun.spawnSync([tmuxCommand, ...args], { stdout: "pipe", stderr: "pipe" });
1930
1963
  if (result.exitCode !== 0) {
1931
1964
  // Distinguish "you are not inside any tmux session" from a genuine tmux
1932
1965
  // query failure so the caller gets actionable guidance instead of raw
1933
1966
  // tmux stderr. `gjc team` needs a tmux leader; outside tmux there is none.
1934
- const insideTmux = Boolean(env.TMUX?.trim() || env.TMUX_PANE?.trim());
1967
+ const insideTmux = Boolean(env.TMUX?.trim() || env.TMUX_PANE?.trim() || activeSession);
1935
1968
  const stderr = result.stderr.toString().trim();
1936
1969
  throw new Error(
1937
1970
  buildTeamTmuxLeaderRequirementMessage(
@@ -2987,7 +3020,9 @@ export async function startGjcTeam(options: GjcTeamStartOptions): Promise<GjcTea
2987
3020
  const dir = teamDir(stateRoot, teamName);
2988
3021
  const createdAt = now();
2989
3022
  const worktreeMode = resolveDefaultWorktreeMode(options.worktreeMode);
2990
- const tmuxCommand = resolveGjcTmuxCommand(env);
3023
+ const platform = options.platform ?? process.platform;
3024
+ const tmuxBinary = resolveGjcTmuxBinary({ env, platform });
3025
+ const tmuxCommand = tmuxBinary.command;
2991
3026
  const tmuxContext = options.dryRun
2992
3027
  ? { sessionName: "dry-run", windowIndex: "0", leaderPaneId: "%dry-run-leader", target: "dry-run:0" }
2993
3028
  : readCurrentTmuxLeaderContext(tmuxCommand, env);
@@ -2996,7 +3031,11 @@ export async function startGjcTeam(options: GjcTeamStartOptions): Promise<GjcTea
2996
3031
  const workers: GjcTeamWorker[] = [];
2997
3032
  try {
2998
3033
  for (const worker of initialWorkers)
2999
- workers.push(options.dryRun ? worker : await ensureWorkerWorktree(cwd, dir, teamName, worker, worktreeMode));
3034
+ workers.push(
3035
+ options.dryRun
3036
+ ? worker
3037
+ : await ensureWorkerWorktree(cwd, dir, teamName, worker, worktreeMode, platform, tmuxBinary.isPsmux),
3038
+ );
3000
3039
  } catch (error) {
3001
3040
  await rollbackCreatedWorktrees(workers);
3002
3041
  throw error;
@@ -4,6 +4,7 @@ import { resolveGjcTmuxBinary } from "./psmux-detect";
4
4
  export const GJC_DEFAULT_TMUX_SESSION = "gajae_code";
5
5
  export const GJC_TMUX_SESSION_PREFIX = `${GJC_DEFAULT_TMUX_SESSION}_`;
6
6
  export const GJC_TMUX_COMMAND_ENV = "GJC_TMUX_COMMAND";
7
+ export const GJC_TMUX_ACTIVE_SESSION_ENV = "GJC_TMUX_ACTIVE_SESSION";
7
8
  export const GJC_TMUX_PROFILE_ENV = "GJC_TMUX_PROFILE";
8
9
  export const GJC_TMUX_MOUSE_ENV = "GJC_MOUSE";
9
10
  export const GJC_TMUX_PROFILE_OPTION = "@gjc-profile";
@@ -4544,6 +4544,15 @@ function parseGitNameStatus(output: string): UltragoalChangeSetPath[] {
4544
4544
  return rows;
4545
4545
  }
4546
4546
 
4547
+ function categorizeCiChangedPath(value: string): UltragoalChangeCategory {
4548
+ // CI_DEV_CHANGED_PATHS intentionally carries path names only. Mixed registries
4549
+ // such as settings-schema.ts require diff-level narrowing; without the diff,
4550
+ // treating the whole registry as computer-control source forces the mandatory
4551
+ // computer red-team suite on unrelated settings changes.
4552
+ if (isSettingsSchemaPath(value)) return "other";
4553
+ return categorizeComputerChangePath(value);
4554
+ }
4555
+
4547
4556
  function ciDevChangedPathRows(): UltragoalChangeSetPath[] {
4548
4557
  const raw = process.env.CI_DEV_CHANGED_PATHS;
4549
4558
  if (!raw) return [];
@@ -4554,7 +4563,7 @@ function ciDevChangedPathRows(): UltragoalChangeSetPath[] {
4554
4563
  .map(pathValue => ({
4555
4564
  path: normalizeRepoPath(pathValue),
4556
4565
  status: "unknown" as UltragoalChangeStatus,
4557
- category: categorizeComputerChangePath(pathValue),
4566
+ category: categorizeCiChangedPath(pathValue),
4558
4567
  }));
4559
4568
  }
4560
4569