@gajae-code/coding-agent 0.9.1 → 0.9.3

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 (123) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/types/capability/mcp.d.ts +3 -1
  3. package/dist/types/cli/args.d.ts +1 -0
  4. package/dist/types/cli/notify-cli.d.ts +5 -0
  5. package/dist/types/cli.d.ts +3 -0
  6. package/dist/types/commands/launch.d.ts +3 -0
  7. package/dist/types/commands/mcp.d.ts +1 -1
  8. package/dist/types/config/model-profile-activation.d.ts +7 -7
  9. package/dist/types/config/model-registry.d.ts +7 -3
  10. package/dist/types/config/model-resolver.d.ts +2 -2
  11. package/dist/types/config/settings-schema.d.ts +1 -1
  12. package/dist/types/config/settings.d.ts +7 -0
  13. package/dist/types/extensibility/runtime-skill-discovery.d.ts +20 -0
  14. package/dist/types/gjc-runtime/team-runtime.d.ts +9 -0
  15. package/dist/types/modes/components/settings-selector.d.ts +2 -2
  16. package/dist/types/modes/components/status-line/types.d.ts +6 -0
  17. package/dist/types/modes/components/status-line.d.ts +1 -0
  18. package/dist/types/modes/components/thinking-selector.d.ts +6 -1
  19. package/dist/types/modes/types.d.ts +1 -0
  20. package/dist/types/modes/utils/ui-helpers.d.ts +12 -0
  21. package/dist/types/notifications/config-commands.d.ts +29 -0
  22. package/dist/types/notifications/index.d.ts +12 -1
  23. package/dist/types/notifications/telegram-daemon-cli.d.ts +3 -3
  24. package/dist/types/notifications/threaded-render.d.ts +2 -0
  25. package/dist/types/notifications/topic-registry.d.ts +4 -5
  26. package/dist/types/runtime-credential-selector.d.ts +7 -0
  27. package/dist/types/runtime-mcp/config-writer.d.ts +4 -3
  28. package/dist/types/runtime-mcp/types.d.ts +5 -4
  29. package/dist/types/sdk.d.ts +7 -1
  30. package/dist/types/session/agent-session.d.ts +22 -3
  31. package/dist/types/session/session-manager.d.ts +1 -0
  32. package/dist/types/tools/index.d.ts +1 -0
  33. package/dist/types/tools/skill-discovery.d.ts +40 -0
  34. package/dist/types/tools/skill.d.ts +9 -8
  35. package/dist/types/utils/linkedom.d.ts +4 -0
  36. package/dist/types/utils/pasted-image-path.d.ts +5 -3
  37. package/package.json +11 -7
  38. package/scripts/build-binary.ts +2 -40
  39. package/scripts/compile-args.ts +91 -0
  40. package/scripts/generate-docs-index.ts +1 -0
  41. package/src/capability/mcp.ts +3 -1
  42. package/src/cli/args.ts +8 -0
  43. package/src/cli/mcp-cli.ts +108 -26
  44. package/src/cli/notify-cli.ts +27 -1
  45. package/src/cli.ts +26 -0
  46. package/src/commands/launch.ts +5 -0
  47. package/src/commands/mcp.ts +8 -8
  48. package/src/commands/notify.ts +2 -2
  49. package/src/commands/stats.ts +6 -2
  50. package/src/config/mcp-schema.json +21 -17
  51. package/src/config/model-profile-activation.ts +23 -7
  52. package/src/config/model-registry.ts +21 -4
  53. package/src/config/model-resolver.ts +5 -16
  54. package/src/config/settings-schema.ts +3 -2
  55. package/src/config/settings.ts +24 -1
  56. package/src/coordinator-mcp/server.ts +7 -4
  57. package/src/discovery/builtin.ts +14 -0
  58. package/src/discovery/mcp-json.ts +14 -0
  59. package/src/extensibility/runtime-skill-discovery.ts +229 -0
  60. package/src/gjc-runtime/launch-tmux.ts +72 -12
  61. package/src/gjc-runtime/psmux-detect.ts +17 -2
  62. package/src/gjc-runtime/state-runtime.ts +105 -12
  63. package/src/gjc-runtime/team-runtime.ts +35 -3
  64. package/src/gjc-runtime/workflow-manifest.generated.json +4 -28
  65. package/src/gjc-runtime/workflow-manifest.ts +1 -7
  66. package/src/internal-urls/docs-index.generated.ts +3 -2
  67. package/src/internal-urls/gjc-protocol.ts +14 -1
  68. package/src/main.ts +26 -3
  69. package/src/modes/components/assistant-message.ts +1 -1
  70. package/src/modes/components/settings-selector.ts +3 -3
  71. package/src/modes/components/status-line/presets.ts +5 -7
  72. package/src/modes/components/status-line/segments.ts +14 -0
  73. package/src/modes/components/status-line/types.ts +6 -0
  74. package/src/modes/components/status-line.ts +7 -1
  75. package/src/modes/components/thinking-selector.ts +79 -18
  76. package/src/modes/components/tool-execution.ts +1 -1
  77. package/src/modes/controllers/event-controller.ts +9 -9
  78. package/src/modes/controllers/input-controller.ts +52 -11
  79. package/src/modes/controllers/selector-controller.ts +7 -5
  80. package/src/modes/interactive-mode.ts +12 -4
  81. package/src/modes/types.ts +1 -0
  82. package/src/modes/utils/ui-helpers.ts +202 -47
  83. package/src/notifications/config-commands.ts +70 -0
  84. package/src/notifications/index.ts +156 -9
  85. package/src/notifications/rich-render.ts +1 -0
  86. package/src/notifications/telegram-daemon-cli.ts +13 -5
  87. package/src/notifications/telegram-daemon.ts +87 -4
  88. package/src/notifications/threaded-render.ts +14 -0
  89. package/src/notifications/topic-registry.ts +9 -8
  90. package/src/prompts/tools/skill-discovery.md +13 -0
  91. package/src/prompts/tools/skill.md +2 -2
  92. package/src/runtime-credential-selector.ts +35 -0
  93. package/src/runtime-mcp/config-writer.ts +4 -3
  94. package/src/runtime-mcp/config.ts +1 -0
  95. package/src/runtime-mcp/types.ts +5 -4
  96. package/src/sdk.ts +68 -5
  97. package/src/session/agent-session.ts +159 -21
  98. package/src/session/session-manager.ts +68 -40
  99. package/src/system-prompt.ts +4 -6
  100. package/src/tools/bash-interactive.ts +9 -4
  101. package/src/tools/browser/readable.ts +2 -2
  102. package/src/tools/fetch.ts +5 -5
  103. package/src/tools/index.ts +4 -0
  104. package/src/tools/skill-discovery.ts +73 -0
  105. package/src/tools/skill.ts +29 -13
  106. package/src/utils/linkedom.ts +14 -0
  107. package/src/utils/pasted-image-path.ts +23 -9
  108. package/src/web/scrapers/arxiv.ts +2 -2
  109. package/src/web/scrapers/go-pkg.ts +2 -2
  110. package/src/web/scrapers/iacr.ts +2 -2
  111. package/src/web/scrapers/readthedocs.ts +2 -2
  112. package/src/web/scrapers/twitter.ts +3 -2
  113. package/src/web/scrapers/wikipedia.ts +2 -2
  114. package/src/export/html/template.css +0 -1060
  115. package/src/export/html/template.html +0 -47
  116. package/src/export/html/template.js +0 -2314
  117. package/vendor/insane-search/engine/tests/test_hardening.py +0 -57
  118. package/vendor/insane-search/engine/tests/test_smoke.py +0 -152
  119. package/vendor/insane-search/engine/tests/test_u1.py +0 -200
  120. package/vendor/insane-search/engine/tests/test_u4.py +0 -131
  121. package/vendor/insane-search/engine/tests/test_u5.py +0 -163
  122. package/vendor/insane-search/engine/tests/test_u7.py +0 -124
  123. package/vendor/insane-search/engine/tests/test_u8.py +0 -216
@@ -30,6 +30,7 @@ interface MCPConfigFile {
30
30
  command?: string;
31
31
  args?: string[];
32
32
  env?: Record<string, string>;
33
+ noInheritEnv?: boolean;
33
34
  cwd?: string;
34
35
  url?: string;
35
36
  headers?: Record<string, string>;
@@ -92,6 +93,18 @@ function transformMCPConfig(config: MCPConfigFile, source: SourceMeta): MCPServe
92
93
  }
93
94
  }
94
95
 
96
+ let noInheritEnv: boolean | undefined;
97
+ if (serverConfig.noInheritEnv !== undefined) {
98
+ if (typeof serverConfig.noInheritEnv === "boolean") {
99
+ noInheritEnv = serverConfig.noInheritEnv;
100
+ } else {
101
+ logger.warn("MCP server has invalid 'noInheritEnv' value, ignoring", {
102
+ name,
103
+ value: serverConfig.noInheritEnv,
104
+ });
105
+ }
106
+ }
107
+
95
108
  const server: MCPServer = {
96
109
  name,
97
110
  enabled,
@@ -100,6 +113,7 @@ function transformMCPConfig(config: MCPConfigFile, source: SourceMeta): MCPServe
100
113
  command: serverConfig.command,
101
114
  args: serverConfig.args,
102
115
  env: serverConfig.env,
116
+ noInheritEnv,
103
117
  cwd: serverConfig.cwd,
104
118
  url: serverConfig.url,
105
119
  headers: serverConfig.headers,
@@ -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
+ }
@@ -158,11 +158,43 @@ export interface GjcTmuxProfileContext {
158
158
  version?: string | null;
159
159
  }
160
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
+
161
192
  interface CommandResolutionContext {
162
193
  cwd: string;
163
194
  argv: string[];
164
195
  execPath: string;
165
196
  extraEnv?: Record<string, string>;
197
+ tmuxExitMarkerPath?: string;
166
198
  platform?: NodeJS.Platform;
167
199
  }
168
200
 
@@ -286,7 +318,17 @@ function buildWindowsPowerShellInnerCommand(context: CommandResolutionContext, r
286
318
  const innerArgs = stripRootTmuxFlag(rawArgs).map(powershellQuote).join(" ");
287
319
  const invocation = `& ${resolvedCommand} ${innerArgs}`;
288
320
  const exitLine = "if ($null -ne $LASTEXITCODE) { exit $LASTEXITCODE } else { exit 1 }";
289
- 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");
290
332
  // Encode the script as UTF-16LE base64 for pwsh -EncodedCommand. Do NOT
291
333
  // prepend a UTF-16LE BOM (0xFF 0xFE): the BOM survives the decode and is
292
334
  // inserted as a literal U+FEFF character in front of the first script
@@ -366,7 +408,9 @@ function buildInnerCommand(context: CommandResolutionContext, rawArgs: string[])
366
408
  if (isWindowsPlatform(context.platform)) return buildWindowsPowerShellInnerCommand(context, rawArgs);
367
409
  const command = resolveCurrentGjcCommand(context);
368
410
  const quoted = [...command, ...stripRootTmuxFlag(rawArgs)].map(shellQuote).join(" ");
369
- 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 $?`;
370
414
  }
371
415
 
372
416
  function visibleWidth(value: string): number {
@@ -404,6 +448,9 @@ function truncateVisibleTail(value: string, maxWidth: number): string {
404
448
  const GJC_TMUX_WINDOW_BRANCH_SEPARATOR = "-";
405
449
  const GJC_TMUX_WINDOW_TITLE_PREFIX = "GJC-";
406
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}";
407
454
 
408
455
  function sanitizeTmuxWindowTitleSegment(value: string): string {
409
456
  return value.replace(/:+/g, "-");
@@ -445,17 +492,31 @@ function sanitizeGjcTmuxRootTerminalTitle(title: string): string {
445
492
  return title.replace(TERMINAL_TITLE_CONTROL_CHARS, "").trim() || "GJC";
446
493
  }
447
494
 
448
- function escapeTmuxFormatLiteral(value: string): string {
449
- 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}}`;
450
498
  }
451
499
 
452
- function buildGjcTmuxRootTerminalTitleCommands(target: string, title: string): GjcTmuxProfileCommand[] {
453
- 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);
454
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
+ },
455
516
  { description: "enable tmux client terminal title", args: ["set-option", "-t", target, "set-titles", "on"] },
456
517
  {
457
- description: "set tmux client terminal title",
458
- 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],
459
520
  },
460
521
  ];
461
522
  }
@@ -468,10 +529,8 @@ function applyGjcTmuxRootTerminalTitleProfile(context: {
468
529
  options: TmuxSpawnOptions;
469
530
  }): void {
470
531
  if (!context.title) return;
471
- for (const command of buildGjcTmuxRootTerminalTitleCommands(
472
- buildGjcTmuxExactOptionTarget(context.target, { env: context.options.env }),
473
- context.title,
474
- )) {
532
+ const optionTarget = buildGjcTmuxExactOptionTarget(context.target, { env: context.options.env });
533
+ for (const command of buildGjcTmuxRootTerminalTitleCommands(optionTarget, context.target, context.title)) {
475
534
  context.spawnSync(context.tmuxCommand, command.args, context.options);
476
535
  }
477
536
  }
@@ -695,6 +754,7 @@ export function buildDefaultTmuxLaunchPlan(context: TmuxLaunchContext): TmuxLaun
695
754
  // session, which would split/send workers into the wrong session.
696
755
  [GJC_TMUX_ACTIVE_SESSION_ENV]: sessionName,
697
756
  },
757
+ tmuxExitMarkerPath: tmuxExitMarkerPath(sessionStateFile),
698
758
  platform,
699
759
  },
700
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
  }
@@ -242,9 +242,12 @@ function assertSafePathComponent(value: string, label: string): void {
242
242
  throw new StateCommandError(2, `invalid path component for --${label}: ${value}`);
243
243
  }
244
244
  }
245
+ function isKnownMode(mode: string): mode is CanonicalGjcWorkflowSkill {
246
+ return KNOWN_MODES.includes(mode);
247
+ }
245
248
 
246
249
  function assertKnownMode(mode: string): asserts mode is CanonicalGjcWorkflowSkill {
247
- if (!KNOWN_MODES.includes(mode)) {
250
+ if (!isKnownMode(mode)) {
248
251
  throw new StateCommandError(2, `unknown --mode: ${mode}. Expected one of: ${KNOWN_MODES.join(", ")}.`);
249
252
  }
250
253
  }
@@ -1580,14 +1583,15 @@ async function handleHandoff(
1580
1583
  if (!calleeRaw) {
1581
1584
  throw new StateCommandError(2, "gjc state handoff requires --to <callee>");
1582
1585
  }
1583
- assertKnownMode(calleeRaw);
1584
- const callee = calleeRaw as CanonicalGjcWorkflowSkill;
1586
+ assertSafePathComponent(calleeRaw, "to");
1587
+ const callee = calleeRaw;
1588
+ const calleeIsWorkflow = isKnownMode(callee);
1585
1589
  if (callee === caller) {
1586
1590
  throw new StateCommandError(2, `gjc state handoff: --to must differ from caller (both are "${caller}")`);
1587
1591
  }
1588
1592
 
1589
1593
  const callerPath = modeStateFile(cwd, caller, sessionId);
1590
- const calleePath = modeStateFile(cwd, callee, sessionId);
1594
+ const calleePath = calleeIsWorkflow ? modeStateFile(cwd, callee, sessionId) : undefined;
1591
1595
  const forced = hasFlag(args, "--force");
1592
1596
  const callerRead = await readExistingStateForMutation(callerPath);
1593
1597
  if (callerRead.kind === "corrupt" && !forced) {
@@ -1602,15 +1606,7 @@ async function handleHandoff(
1602
1606
  `gjc state ${caller} handoff: caller is not active (no mode-state file at ${callerPath})`,
1603
1607
  );
1604
1608
  }
1605
- const calleeRead = await readExistingStateForMutation(calleePath);
1606
- if (calleeRead.kind === "corrupt" && !forced) {
1607
- throw new StateCommandError(
1608
- 2,
1609
- `existing state for ${callee} is corrupt or tampered (${calleeRead.error}); use --force to overwrite`,
1610
- );
1611
- }
1612
1609
  const existingCaller = callerRead.kind === "valid" ? callerRead.value : {};
1613
- const existingCallee = calleeRead.kind === "valid" ? calleeRead.value : {};
1614
1610
 
1615
1611
  const handoffAt = nowIso();
1616
1612
  const mutationId = `${caller}:handoff:${callee}:${handoffAt}`;
@@ -1623,6 +1619,103 @@ async function handleHandoff(
1623
1619
  nowIso: handoffAt,
1624
1620
  mutationId,
1625
1621
  });
1622
+
1623
+ // Runtime callees have no native mode-state to clear later, so do not
1624
+ // persist them as active-state entries; the prompt observer tracks them
1625
+ // in memory the same way direct `/skill:<runtime>` invocation does.
1626
+ if (!calleeIsWorkflow) {
1627
+ const normalizedCaller =
1628
+ caller === "deep-interview"
1629
+ ? (normalizeDeepInterviewEnvelope(migrateWorkflowState(existingCaller, caller).state) as Record<
1630
+ string,
1631
+ unknown
1632
+ >)
1633
+ : migrateWorkflowState(existingCaller, caller).state;
1634
+ const mergedCallerState: Record<string, unknown> = {
1635
+ ...normalizedCaller,
1636
+ skill: caller,
1637
+ version: WORKFLOW_STATE_VERSION,
1638
+ active: false,
1639
+ current_phase: "handoff",
1640
+ handoff_to: callee,
1641
+ handoff_at: handoffAt,
1642
+ updated_at: handoffAt,
1643
+ receipt: callerReceipt,
1644
+ };
1645
+ const force = hasFlag(args, "--force");
1646
+ await beginWorkflowTransactionJournal({
1647
+ cwd,
1648
+ sessionId,
1649
+ mutationId,
1650
+ caller,
1651
+ paths: [callerPath, activeStateFile(cwd, sessionId)],
1652
+ });
1653
+ const callerWrite = await writeJsonAtomic(cwd, callerPath, mergedCallerState, "handoff", {
1654
+ sessionId,
1655
+ skill: caller,
1656
+ mutationId,
1657
+ force,
1658
+ fromPhase: typeof existingCaller.current_phase === "string" ? existingCaller.current_phase : undefined,
1659
+ toPhase: "handoff",
1660
+ });
1661
+ await updateWorkflowTransactionJournal(cwd, sessionId, mutationId, { steps: ["caller-mode-state"] });
1662
+ if (callerWrite.warning) process.stderr.write(`${callerWrite.warning}\n`);
1663
+ const stampedCallerReceipt = isPlainObject(callerWrite.stamped.receipt) ? callerWrite.stamped.receipt : {};
1664
+ await syncSkillActiveState({
1665
+ cwd,
1666
+ skill: caller,
1667
+ active: false,
1668
+ phase: "handoff",
1669
+ sessionId,
1670
+ threadId,
1671
+ turnId,
1672
+ source: "gjc-state-cli",
1673
+ hud: buildHudForMode(caller, mergedCallerState),
1674
+ handoff_to: callee,
1675
+ handoff_at: handoffAt,
1676
+ receipt: callerReceipt,
1677
+ });
1678
+ await updateWorkflowTransactionJournal(cwd, sessionId, mutationId, {
1679
+ steps: ["caller-mode-state", "active-state"],
1680
+ });
1681
+ await completeWorkflowTransactionJournal(cwd, sessionId, mutationId);
1682
+ await touchStateActivityMarker(cwd, sessionId, callerPath);
1683
+ return {
1684
+ status: 0,
1685
+ stdout: renderCliWriteReceipt({
1686
+ ok: true,
1687
+ from: caller,
1688
+ to: callee,
1689
+ handoff_at: handoffAt,
1690
+ phases: {
1691
+ from: mergedCallerState.current_phase,
1692
+ },
1693
+ receipts: {
1694
+ from: {
1695
+ mutation_id: stampedCallerReceipt.mutation_id,
1696
+ status: stampedCallerReceipt.status,
1697
+ content_sha256: stampedCallerReceipt.content_sha256,
1698
+ },
1699
+ },
1700
+ paths: {
1701
+ from: callerPath,
1702
+ active_state: activeStateFile(cwd, sessionId),
1703
+ },
1704
+ }),
1705
+ };
1706
+ }
1707
+
1708
+ if (!calleePath) {
1709
+ throw new StateCommandError(2, `gjc state handoff failed to resolve workflow callee path for ${callee}`);
1710
+ }
1711
+ const calleeRead = await readExistingStateForMutation(calleePath);
1712
+ if (calleeRead.kind === "corrupt" && !forced) {
1713
+ throw new StateCommandError(
1714
+ 2,
1715
+ `existing state for ${callee} is corrupt or tampered (${calleeRead.error}); use --force to overwrite`,
1716
+ );
1717
+ }
1718
+ const existingCallee = calleeRead.kind === "valid" ? calleeRead.value : {};
1626
1719
  const calleeReceipt = buildWorkflowStateReceipt({
1627
1720
  cwd,
1628
1721
  skill: callee,
@@ -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";
@@ -25,6 +26,7 @@ import {
25
26
  GJC_TMUX_ACTIVE_SESSION_ENV,
26
27
  GJC_TMUX_PROFILE_OPTION,
27
28
  GJC_TMUX_PROFILE_VALUE,
29
+ resolveGjcTmuxBinary,
28
30
  resolveGjcTmuxCommand,
29
31
  } from "./tmux-common";
30
32
 
@@ -288,6 +290,7 @@ export interface GjcTeamStartOptions {
288
290
  cwd?: string;
289
291
  env?: NodeJS.ProcessEnv;
290
292
  dryRun?: boolean;
293
+ platform?: NodeJS.Platform;
291
294
  mailboxDeliveryTransport?: GjcTeamMailboxDeliveryTransport;
292
295
  }
293
296
 
@@ -1844,18 +1847,41 @@ function findWorktreePath(repoRoot: string, worktreePath: string): string | null
1844
1847
  if (line.startsWith("worktree ") && path.resolve(line.slice("worktree ".length)) === resolved) return resolved;
1845
1848
  return null;
1846
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
+ }
1847
1864
  async function ensureWorkerWorktree(
1848
1865
  cwd: string,
1849
1866
  dir: string,
1850
1867
  teamName: string,
1851
1868
  worker: GjcTeamWorker,
1852
1869
  mode: GjcTeamWorktreeMode,
1870
+ platform: NodeJS.Platform,
1871
+ isPsmux: boolean,
1853
1872
  ): Promise<GjcTeamWorker> {
1854
1873
  if (!mode.enabled) return worker;
1855
1874
  if (!isGitRepository(cwd)) throw new Error(`team_worktree_requires_git_repo:${cwd}`);
1856
1875
  const repoRoot = runGit(cwd, ["rev-parse", "--show-toplevel"]);
1857
1876
  const baseRef = runGit(repoRoot, ["rev-parse", "HEAD"]);
1858
- 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
+ });
1859
1885
  const existing = findWorktreePath(repoRoot, worktreePath);
1860
1886
  let created = false;
1861
1887
  const branchName = mode.detached
@@ -2994,7 +3020,9 @@ export async function startGjcTeam(options: GjcTeamStartOptions): Promise<GjcTea
2994
3020
  const dir = teamDir(stateRoot, teamName);
2995
3021
  const createdAt = now();
2996
3022
  const worktreeMode = resolveDefaultWorktreeMode(options.worktreeMode);
2997
- const tmuxCommand = resolveGjcTmuxCommand(env);
3023
+ const platform = options.platform ?? process.platform;
3024
+ const tmuxBinary = resolveGjcTmuxBinary({ env, platform });
3025
+ const tmuxCommand = tmuxBinary.command;
2998
3026
  const tmuxContext = options.dryRun
2999
3027
  ? { sessionName: "dry-run", windowIndex: "0", leaderPaneId: "%dry-run-leader", target: "dry-run:0" }
3000
3028
  : readCurrentTmuxLeaderContext(tmuxCommand, env);
@@ -3003,7 +3031,11 @@ export async function startGjcTeam(options: GjcTeamStartOptions): Promise<GjcTea
3003
3031
  const workers: GjcTeamWorker[] = [];
3004
3032
  try {
3005
3033
  for (const worker of initialWorkers)
3006
- 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
+ );
3007
3039
  } catch (error) {
3008
3040
  await rollbackCreatedWorktrees(workers);
3009
3041
  throw error;