@gajae-code/coding-agent 0.7.6 → 0.7.8

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 (111) hide show
  1. package/CHANGELOG.md +26 -1
  2. package/README.md +8 -0
  3. package/dist/types/cli/local-provider-smoke.d.ts +59 -0
  4. package/dist/types/cli/notify-cli.d.ts +1 -1
  5. package/dist/types/cli/update-cli.d.ts +24 -1
  6. package/dist/types/commands/local-provider.d.ts +34 -0
  7. package/dist/types/config/model-profiles.d.ts +2 -2
  8. package/dist/types/config/model-registry.d.ts +8 -3
  9. package/dist/types/config/models-config-schema.d.ts +5 -5
  10. package/dist/types/config/settings-schema.d.ts +123 -9
  11. package/dist/types/config/settings.d.ts +7 -0
  12. package/dist/types/coordinator-mcp/server.d.ts +8 -0
  13. package/dist/types/export/html/template.generated.d.ts +1 -1
  14. package/dist/types/gjc-runtime/launch-tmux.d.ts +2 -2
  15. package/dist/types/gjc-runtime/state-renderer.d.ts +5 -0
  16. package/dist/types/gjc-runtime/team-runtime.d.ts +1 -1
  17. package/dist/types/gjc-runtime/tmux-common.d.ts +12 -0
  18. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +37 -1
  19. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +80 -0
  20. package/dist/types/modes/components/custom-model-preset-wizard.d.ts +1 -1
  21. package/dist/types/modes/components/model-selector.d.ts +2 -0
  22. package/dist/types/modes/components/settings-selector.d.ts +4 -1
  23. package/dist/types/modes/components/status-line/types.d.ts +5 -7
  24. package/dist/types/modes/components/status-line.d.ts +4 -3
  25. package/dist/types/modes/controllers/input-controller.d.ts +1 -0
  26. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  27. package/dist/types/modes/prompt-action-autocomplete.d.ts +1 -0
  28. package/dist/types/modes/tmux-scroll.d.ts +18 -0
  29. package/dist/types/modes/utils/terminal-bell.d.ts +3 -0
  30. package/dist/types/notifications/telegram-daemon-cli.d.ts +13 -2
  31. package/dist/types/tools/browser/tab-supervisor.d.ts +31 -0
  32. package/dist/types/tools/computer-gc.d.ts +23 -0
  33. package/dist/types/tools/cron.d.ts +36 -61
  34. package/dist/types/tools/index.d.ts +0 -1
  35. package/dist/types/tools/resource-gc.d.ts +54 -0
  36. package/dist/types/web/search/index.d.ts +1 -0
  37. package/dist/types/web/search/providers/utils.d.ts +11 -4
  38. package/package.json +7 -7
  39. package/src/cli/args.ts +0 -1
  40. package/src/cli/fast-help.ts +0 -1
  41. package/src/cli/local-provider-smoke.ts +632 -0
  42. package/src/cli/notify-cli.ts +4 -2
  43. package/src/cli/update-cli.ts +155 -13
  44. package/src/cli/web-search-cli.ts +5 -0
  45. package/src/cli.ts +18 -0
  46. package/src/commands/launch.ts +46 -1
  47. package/src/commands/local-provider.ts +62 -0
  48. package/src/commands/notify.ts +1 -1
  49. package/src/config/model-profiles.ts +3 -4
  50. package/src/config/model-registry.ts +58 -7
  51. package/src/config/models-config-schema.ts +10 -1
  52. package/src/config/settings-schema.ts +127 -10
  53. package/src/config/settings.ts +11 -0
  54. package/src/coordinator-mcp/server.ts +276 -34
  55. package/src/export/html/template.generated.ts +1 -1
  56. package/src/export/html/template.js +0 -12
  57. package/src/gjc-runtime/launch-tmux.ts +34 -8
  58. package/src/gjc-runtime/launch-worktree.ts +16 -2
  59. package/src/gjc-runtime/state-renderer.ts +13 -0
  60. package/src/gjc-runtime/team-runtime.ts +21 -6
  61. package/src/gjc-runtime/tmux-common.ts +16 -0
  62. package/src/gjc-runtime/tmux-sessions.ts +13 -9
  63. package/src/gjc-runtime/ultragoal-guard.ts +167 -0
  64. package/src/gjc-runtime/ultragoal-runtime.ts +221 -1
  65. package/src/goals/tools/goal-tool.ts +14 -1
  66. package/src/internal-urls/docs-index.generated.ts +7 -7
  67. package/src/modes/components/agent-dashboard.ts +2 -1
  68. package/src/modes/components/custom-model-preset-wizard.ts +30 -189
  69. package/src/modes/components/model-selector.ts +182 -5
  70. package/src/modes/components/session-observer-overlay.ts +3 -3
  71. package/src/modes/components/settings-selector.ts +491 -5
  72. package/src/modes/components/status-line/segments.ts +7 -18
  73. package/src/modes/components/status-line/types.ts +6 -2
  74. package/src/modes/components/status-line.ts +69 -35
  75. package/src/modes/controllers/event-controller.ts +98 -4
  76. package/src/modes/controllers/extension-ui-controller.ts +3 -0
  77. package/src/modes/controllers/input-controller.ts +15 -0
  78. package/src/modes/controllers/selector-controller.ts +18 -4
  79. package/src/modes/prompt-action-autocomplete.ts +8 -0
  80. package/src/modes/tmux-scroll.ts +92 -0
  81. package/src/modes/utils/terminal-bell.ts +48 -0
  82. package/src/notifications/telegram-daemon-cli.ts +112 -9
  83. package/src/notifications/telegram-daemon.ts +4 -0
  84. package/src/prompts/system/system-prompt.md +2 -2
  85. package/src/prompts/tools/cron.md +5 -3
  86. package/src/prompts/tools/read.md +1 -1
  87. package/src/sdk.ts +5 -0
  88. package/src/session/agent-session.ts +118 -5
  89. package/src/slash-commands/builtin-registry.ts +106 -13
  90. package/src/tools/browser/tab-supervisor.ts +86 -2
  91. package/src/tools/computer-gc.ts +66 -0
  92. package/src/tools/computer.ts +2 -0
  93. package/src/tools/cron.ts +75 -112
  94. package/src/tools/index.ts +2 -8
  95. package/src/tools/read.ts +25 -55
  96. package/src/tools/renderers.ts +0 -2
  97. package/src/tools/resource-gc.ts +291 -0
  98. package/src/tools/ultragoal-ask-guard.ts +7 -1
  99. package/src/web/search/index.ts +1 -0
  100. package/src/web/search/providers/utils.ts +22 -5
  101. package/vendor/insane-search/MANIFEST.json +3 -1
  102. package/vendor/insane-search/engine/__init__.py +14 -0
  103. package/vendor/insane-search/engine/content_safety.py +151 -0
  104. package/vendor/insane-search/engine/fetch_chain.py +32 -0
  105. package/vendor/insane-search/engine/tests/test_u8.py +216 -0
  106. package/dist/types/tools/inspect-image-renderer.d.ts +0 -26
  107. package/dist/types/tools/inspect-image.d.ts +0 -31
  108. package/src/prompts/tools/inspect-image-system.md +0 -20
  109. package/src/prompts/tools/inspect-image.md +0 -32
  110. package/src/tools/inspect-image-renderer.ts +0 -103
  111. package/src/tools/inspect-image.ts +0 -172
@@ -1113,17 +1113,6 @@
1113
1113
  return html;
1114
1114
  }
1115
1115
 
1116
- function renderInspectImage(name, args, result, ctx) {
1117
- const p = str(args.path == null ? args.url : args.path) || '';
1118
- let html = toolHead('inspect_image', escapeHtml(shortenPath(p)));
1119
- if (result) {
1120
- html += ctx.renderResultImages();
1121
- const output = ctx.getResultText();
1122
- if (output) html += formatExpandableOutput(output, 8);
1123
- }
1124
- return html;
1125
- }
1126
-
1127
1116
  function renderGenerateImage(name, args, result, ctx) {
1128
1117
  const subject = str(args.subject) || '';
1129
1118
  const badges = args.aspect_ratio ? [String(args.aspect_ratio)] : null;
@@ -1544,7 +1533,6 @@
1544
1533
  debug: renderDebug,
1545
1534
  puppeteer: renderBrowser,
1546
1535
  browser: renderBrowser,
1547
- inspect_image: renderInspectImage,
1548
1536
  generate_image: renderGenerateImage,
1549
1537
  ask: renderAsk,
1550
1538
  resolve: renderResolve,
@@ -6,6 +6,7 @@ import type { Args } from "../cli/args";
6
6
  import { tmuxRuntimeSessionPath } from "./session-layout";
7
7
  import { GJC_COORDINATOR_SESSION_ID_ENV, GJC_COORDINATOR_SESSION_STATE_FILE_ENV } from "./session-state-sidecar";
8
8
  import {
9
+ buildGjcTmuxExactSessionTarget,
9
10
  buildGjcTmuxProfileCommands,
10
11
  buildGjcTmuxSessionName,
11
12
  buildGjcTmuxSessionSlug,
@@ -21,6 +22,7 @@ import {
21
22
  import { findGjcTmuxSessionByName, findGjcTmuxSessionByScope, type GjcTmuxSessionStatus } from "./tmux-sessions";
22
23
 
23
24
  export {
25
+ buildGjcTmuxExactSessionTarget,
24
26
  buildGjcTmuxProfileCommands,
25
27
  GJC_DEFAULT_TMUX_SESSION,
26
28
  GJC_TMUX_COMMAND_ENV,
@@ -201,12 +203,24 @@ function buildWindowsPowerShellInnerCommand(context: CommandResolutionContext, r
201
203
  const envLines = Object.entries({ [GJC_TMUX_LAUNCHED_ENV]: "1", ...(context.extraEnv ?? {}) }).map(
202
204
  ([key, value]) => `$env:${key} = ${powershellQuote(value)}`,
203
205
  );
204
- const invocation = ["&", ...command.map(powershellQuote), ...stripRootTmuxFlag(rawArgs).map(powershellQuote)].join(
205
- " ",
206
- );
206
+ // The inner `&` invocation must wrap the resolved gjc command in a
207
+ // script-block so the trailing PowerShell exec-policy flags from the
208
+ // outer invocation are not forwarded into the bun / node / .bat
209
+ // binary the gjc command resolves to. Without the wrapping, the flags
210
+ // reach the runtime binary and cause an immediate failure that closes
211
+ // the psmux pane before the gjc --tmux attach can land.
212
+ const resolvedCommand = command.map(powershellQuote).join(" ");
213
+ const innerArgs = stripRootTmuxFlag(rawArgs).map(powershellQuote).join(" ");
214
+ const invocation = `& { ${resolvedCommand} ${innerArgs} }`;
207
215
  const exitLine = "if ($null -ne $LASTEXITCODE) { exit $LASTEXITCODE } else { exit 1 }";
208
216
  const script = [...envLines, invocation, exitLine].join("\n");
209
- const encodedCommand = Buffer.from(script, "utf16le").toString("base64");
217
+ // PowerShell -EncodedCommand requires a UTF-16LE BOM (0xFF 0xFE) at the
218
+ // start of the decoded buffer; without it pwsh may misinterpret the
219
+ // leading bytes and reject the script with a parse error, which would
220
+ // kill the psmux pane before the gjc --tmux attach could land.
221
+ const bom = Buffer.from([0xff, 0xfe]);
222
+ const body = Buffer.from(script, "utf16le");
223
+ const encodedCommand = Buffer.concat([bom, body]).toString("base64");
210
224
  return `pwsh -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encodedCommand}`;
211
225
  }
212
226
 
@@ -395,7 +409,11 @@ function readCurrentBranch(cwd: string): string | null {
395
409
  }
396
410
 
397
411
  function cleanupCreatedTmuxSession(plan: TmuxLaunchPlan, spawnSync: TmuxSpawnSync, options: TmuxSpawnOptions): void {
398
- spawnSync(plan.tmuxCommand, ["kill-session", "-t", `=${plan.sessionName}`], options);
412
+ spawnSync(
413
+ plan.tmuxCommand,
414
+ ["kill-session", "-t", buildGjcTmuxExactSessionTarget(plan.sessionName, { env: options.env })],
415
+ options,
416
+ );
399
417
  }
400
418
  function isTmuxAttachDisconnectError(result: TmuxSpawnResult): boolean {
401
419
  if (result.signalCode === "SIGHUP") return true;
@@ -499,7 +517,11 @@ export function launchDefaultTmuxIfNeeded(context: TmuxLaunchContext): boolean {
499
517
  };
500
518
 
501
519
  if (plan.attachSessionName) {
502
- const attached = spawnSync(plan.tmuxCommand, ["attach-session", "-t", `=${plan.attachSessionName}`], options);
520
+ const attached = spawnSync(
521
+ plan.tmuxCommand,
522
+ ["attach-session", "-t", buildGjcTmuxExactSessionTarget(plan.attachSessionName, { env })],
523
+ options,
524
+ );
503
525
  if (attached.exitCode === 0) return true;
504
526
  }
505
527
 
@@ -510,7 +532,7 @@ export function launchDefaultTmuxIfNeeded(context: TmuxLaunchContext): boolean {
510
532
  buildGjcTmuxWindowTitle(plan.project ?? plan.cwd, plan.branch),
511
533
  spawnSync,
512
534
  options,
513
- `=${plan.sessionName}`,
535
+ buildGjcTmuxExactSessionTarget(plan.sessionName, { env }),
514
536
  );
515
537
 
516
538
  const profile = applyGjcTmuxProfile({
@@ -535,7 +557,11 @@ export function launchDefaultTmuxIfNeeded(context: TmuxLaunchContext): boolean {
535
557
  }
536
558
  }
537
559
  if (created.exitCode !== 0) return false;
538
- const attached = spawnSync(plan.tmuxCommand, ["attach-session", "-t", `=${plan.sessionName}`], options);
560
+ const attached = spawnSync(
561
+ plan.tmuxCommand,
562
+ ["attach-session", "-t", buildGjcTmuxExactSessionTarget(plan.sessionName, { env })],
563
+ options,
564
+ );
539
565
  if (attached.exitCode === 0) return true;
540
566
  if (isTmuxAttachDisconnectError(attached)) {
541
567
  (context.diagnosticWriter ?? safeStderrWrite)(formatTmuxLaunchDiagnostic("attach disconnected", attached.stderr));
@@ -118,6 +118,20 @@ function findWorktreeByPath(entries: GitWorktreeEntry[], worktreePath: string):
118
118
  return entries.find(entry => path.resolve(entry.path) === resolved) ?? null;
119
119
  }
120
120
 
121
+ function describeWorktreeEntry(entry: GitWorktreeEntry): string {
122
+ return entry.detached ? `detached HEAD ${entry.head}` : (entry.branchRef ?? `HEAD ${entry.head}`);
123
+ }
124
+
125
+ function formatWorktreeTargetMismatch(plan: GjcLaunchWorktreePlan, existing: GitWorktreeEntry): string {
126
+ const expected = plan.detached ? `detached HEAD ${plan.baseRef}` : `branch refs/heads/${plan.branchName ?? ""}`;
127
+ return [
128
+ `worktree_target_mismatch:${plan.worktreePath}`,
129
+ `GJC launch worktree target is already registered for ${describeWorktreeEntry(existing)}, but this launch expects ${expected}.`,
130
+ `Path: ${plan.worktreePath}`,
131
+ "Refusing to delete or reuse the conflicting worktree automatically. Safe remediation: inspect the path, commit/stash any work, then remove or prune the stale worktree with git worktree remove <path> when it is no longer needed, or choose a different --worktree name.",
132
+ ].join("\n");
133
+ }
134
+
121
135
  function hasBranchInUse(entries: GitWorktreeEntry[], branchName: string, worktreePath: string): boolean {
122
136
  const expectedRef = `refs/heads/${branchName}`;
123
137
  const resolvedPath = path.resolve(worktreePath);
@@ -228,7 +242,7 @@ export function ensureLaunchWorktree(
228
242
  let dirty = isWorktreeDirty(plan.worktreePath);
229
243
  if (plan.detached) {
230
244
  if (!existingAtPath.detached) {
231
- throw new Error(`worktree_target_mismatch:${plan.worktreePath}`);
245
+ throw new Error(formatWorktreeTargetMismatch(plan, existingAtPath));
232
246
  }
233
247
  if (existingAtPath.head !== plan.baseRef) {
234
248
  if (dirty) throw new Error(`worktree_dirty:${plan.worktreePath}`);
@@ -236,7 +250,7 @@ export function ensureLaunchWorktree(
236
250
  dirty = false;
237
251
  }
238
252
  } else if (existingAtPath.branchRef !== expectedBranchRef) {
239
- throw new Error(`worktree_target_mismatch:${plan.worktreePath}`);
253
+ throw new Error(formatWorktreeTargetMismatch(plan, existingAtPath));
240
254
  }
241
255
  return {
242
256
  ...plan,
@@ -256,6 +256,11 @@ export function renderUltragoalStatusMarkdown(summary: {
256
256
  currentGoal?: { id: string; status: string; title?: string; objective?: string };
257
257
  counts: Record<string, number>;
258
258
  goals: unknown[];
259
+ nudgeBudget?: number;
260
+ nudgeCount?: number;
261
+ nudgeRemaining?: number;
262
+ nudgeGoalId?: string;
263
+ nudgeTargetKind?: string;
259
264
  }): string {
260
265
  if (!summary.exists)
261
266
  return `# ultragoal status\n\n- status: missing\n- No ultragoal plan found at ${summary.paths.goalsPath}. Run \`gjc ultragoal create-goals --brief "..."\` first.\n`;
@@ -270,6 +275,14 @@ export function renderUltragoalStatusMarkdown(summary: {
270
275
  ];
271
276
  if (summary.gjcObjective) lines.push(`- objective: ${summary.gjcObjective}`);
272
277
  if (summary.currentGoal) lines.push(`- current: ${summary.currentGoal.id} (${summary.currentGoal.status})`);
278
+ if (summary.nudgeBudget !== undefined && summary.nudgeGoalId) {
279
+ const used = summary.nudgeCount ?? 0;
280
+ const remaining = summary.nudgeRemaining ?? Math.max(0, summary.nudgeBudget - used);
281
+ const target = summary.nudgeTargetKind ? ` target=${summary.nudgeTargetKind}` : "";
282
+ lines.push(
283
+ `- nudge: ${summary.nudgeGoalId} ${used}/${summary.nudgeBudget} used (${remaining} remaining)${target}`,
284
+ );
285
+ }
273
286
  lines.push(`- goals_path: ${summary.paths.goalsPath}`);
274
287
  if (summary.paths.ledgerPath) lines.push(`- ledger_path: ${summary.paths.ledgerPath}`);
275
288
  return `${lines.join("\n")}\n`;
@@ -1896,13 +1896,27 @@ function readCurrentTmuxLeaderContext(tmuxCommand: string, env: NodeJS.ProcessEn
1896
1896
  }
1897
1897
  return { sessionName, windowIndex, leaderPaneId, target: `${sessionName}:${windowIndex}` };
1898
1898
  }
1899
- export function resolveGjcWorkerCommand(cwd = process.cwd(), env: NodeJS.ProcessEnv = process.env): string {
1899
+ export function resolveGjcWorkerCommand(
1900
+ cwd = process.cwd(),
1901
+ env: NodeJS.ProcessEnv = process.env,
1902
+ platform: NodeJS.Platform = process.platform,
1903
+ argv: string[] = process.argv,
1904
+ execPath = process.execPath,
1905
+ ): string {
1900
1906
  const explicit = env.GJC_TEAM_WORKER_COMMAND?.trim();
1901
1907
  if (explicit) return explicit;
1902
- const entrypoint = process.argv[1];
1903
- if (entrypoint?.endsWith(".ts"))
1904
- return `${shellQuote(process.execPath)} ${shellQuote(path.resolve(cwd, entrypoint))}`;
1905
- if (entrypoint && path.basename(entrypoint).startsWith("gjc")) return shellQuote(path.resolve(cwd, entrypoint));
1908
+ const entrypoint = argv[1];
1909
+ if (!entrypoint) return "gjc";
1910
+ const pathModule = platform === "win32" ? path.win32 : path;
1911
+ const resolvedEntrypoint = pathModule.isAbsolute(entrypoint) ? entrypoint : pathModule.resolve(cwd, entrypoint);
1912
+ if (platform === "win32") {
1913
+ if (entrypoint.endsWith(".ts") || entrypoint.endsWith(".js") || entrypoint.endsWith(".mjs"))
1914
+ return `${powershellQuote(execPath)} ${powershellQuote(resolvedEntrypoint)}`;
1915
+ if (pathModule.basename(entrypoint).startsWith("gjc")) return powershellQuote(resolvedEntrypoint);
1916
+ return "gjc";
1917
+ }
1918
+ if (entrypoint.endsWith(".ts")) return `${shellQuote(execPath)} ${shellQuote(resolvedEntrypoint)}`;
1919
+ if (path.basename(entrypoint).startsWith("gjc")) return shellQuote(path.resolve(cwd, entrypoint));
1906
1920
  return "gjc";
1907
1921
  }
1908
1922
  /** @internal Exported for unit tests. */
@@ -1937,7 +1951,8 @@ export function buildWorkerCommand(
1937
1951
  ...(worker.worktree_path ? [envAssignment("GJC_TEAM_WORKTREE_PATH", worker.worktree_path)] : []),
1938
1952
  ];
1939
1953
  const joined = platform === "win32" ? envLines.join(" ") : envLines.join(" ");
1940
- return `${joined} ${config.worker_command} ${quote(prompt)}`;
1954
+ const invocation = platform === "win32" ? `& ${config.worker_command}` : config.worker_command;
1955
+ return `${joined} ${invocation} ${quote(prompt)}`;
1941
1956
  }
1942
1957
  interface GjcTeamInitialLane {
1943
1958
  label: string;
@@ -86,6 +86,22 @@ export function buildGjcTmuxExactOptionTarget(
86
86
  return `=${sessionName}:`;
87
87
  }
88
88
 
89
+ /**
90
+ * Build the exact-session target for tmux *session-scoped* commands such as
91
+ * `attach-session` and `kill-session`. Native tmux accepts `=NAME` for an
92
+ * exact session match, but Windows psmux 3.3.x rejects that target form for
93
+ * session commands even though the bare `NAME` resolves. Keep native tmux on
94
+ * exact targets and intentionally use the bare session name for psmux.
95
+ */
96
+ export function buildGjcTmuxExactSessionTarget(
97
+ sessionName: string,
98
+ opts: { env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform; binary?: ResolvedTmuxBinary } = {},
99
+ ): string {
100
+ const binary = opts.binary ?? resolveGjcTmuxBinary({ env: opts.env, platform: opts.platform });
101
+ if (binary.isPsmux) return sessionName;
102
+ return `=${sessionName}`;
103
+ }
104
+
89
105
  export const GJC_TMUX_UNTAGGED_REASON = "gjc_tmux_session_untagged";
90
106
 
91
107
  export function buildGjcTmuxUntaggedSessionHint(tmuxCommand: string): string {
@@ -1,6 +1,7 @@
1
1
  import { resolveGjcTmuxBinary } from "./psmux-detect";
2
2
  import {
3
3
  buildGjcTmuxExactOptionTarget,
4
+ buildGjcTmuxExactSessionTarget,
4
5
  buildGjcTmuxProfileCommands,
5
6
  buildGjcTmuxSessionName,
6
7
  buildGjcTmuxUntaggedSessionError,
@@ -60,7 +61,7 @@ function runTmux(args: string[], env: NodeJS.ProcessEnv = process.env): string {
60
61
 
61
62
  function tryKillSession(sessionName: string, env: NodeJS.ProcessEnv): void {
62
63
  try {
63
- runTmux(["kill-session", "-t", `=${sessionName}`], env);
64
+ runTmux(["kill-session", "-t", buildGjcTmuxExactSessionTarget(sessionName, { env })], env);
64
65
  } catch {
65
66
  // Best-effort cleanup only; preserve the original create/tag failure.
66
67
  }
@@ -360,7 +361,7 @@ export function removeGjcTmuxSession(sessionName: string, env: NodeJS.ProcessEnv
360
361
  if (readProfileForExactTarget(session.name, env) !== GJC_TMUX_PROFILE_VALUE) {
361
362
  throw new Error(`gjc_tmux_session_not_managed:${sessionName}`);
362
363
  }
363
- runTmux(["kill-session", "-t", `=${session.name}`], env);
364
+ runTmux(["kill-session", "-t", buildGjcTmuxExactSessionTarget(session.name, { env })], env);
364
365
  return session;
365
366
  }
366
367
 
@@ -404,18 +405,21 @@ export function forceCloseGjcTmuxSession(
404
405
  }
405
406
  }
406
407
  // Intentionally NOT refusing live/attached panes — force-close is hard-kill.
407
- runTmux(["kill-session", "-t", `=${session.name}`], env);
408
+ runTmux(["kill-session", "-t", buildGjcTmuxExactSessionTarget(session.name, { env })], env);
408
409
  return session;
409
410
  }
410
411
 
411
412
  export function attachGjcTmuxSession(sessionName: string, env: NodeJS.ProcessEnv = process.env): never {
412
413
  const session = statusGjcTmuxSession(sessionName, env);
413
414
  const tmuxCommand = resolveGjcTmuxCommand(env);
414
- const result = Bun.spawnSync([tmuxCommand, "attach-session", "-t", `=${session.name}`], {
415
- stdin: "inherit",
416
- stdout: "inherit",
417
- stderr: "inherit",
418
- env,
419
- });
415
+ const result = Bun.spawnSync(
416
+ [tmuxCommand, "attach-session", "-t", buildGjcTmuxExactSessionTarget(session.name, { env })],
417
+ {
418
+ stdin: "inherit",
419
+ stdout: "inherit",
420
+ stderr: "inherit",
421
+ env,
422
+ },
423
+ );
420
424
  process.exit(result.exitCode ?? 1);
421
425
  }
@@ -8,9 +8,13 @@ import {
8
8
  hashStructuredValue,
9
9
  readUltragoalLedger,
10
10
  readUltragoalPlan,
11
+ recordUltragoalNudgeIfBudgetRemaining,
12
+ resolveUltragoalNudgeBudget,
13
+ selectUltragoalNudgeTarget,
11
14
  type UltragoalCompletionVerification,
12
15
  type UltragoalGoal,
13
16
  type UltragoalLedgerEvent,
17
+ type UltragoalNudgeSurface,
14
18
  type UltragoalPaths,
15
19
  type UltragoalPlan,
16
20
  type UltragoalReceiptKind,
@@ -494,6 +498,96 @@ export async function isUltragoalAskBlocked(cwd: string): Promise<UltragoalAskBl
494
498
  });
495
499
  }
496
500
 
501
+ const NUDGE_SURFACE_LABEL: Record<UltragoalNudgeSurface, string> = {
502
+ pause: "pausing the goal",
503
+ drop: "dropping the run",
504
+ ask: "asking the user",
505
+ premature_complete: "finishing the story early",
506
+ };
507
+
508
+ const NUDGE_SURFACE_REASON: Record<UltragoalNudgeSurface, string> = {
509
+ pause: "an active story still has resolvable work",
510
+ drop: "the aggregate run still has unfinished stories",
511
+ ask: "the decision can be resolved as durable story work",
512
+ premature_complete: "story verification is not yet satisfied",
513
+ };
514
+
515
+ /**
516
+ * Escalating per-attempt refusal text. Deliberately avoids every
517
+ * `isUltragoalBypassPrompt` trigger: no `update_goal(`, no "skip/weaken verification",
518
+ * no "mark ... complete", no "--status complete", and the word "complete" never
519
+ * appears (so the `goal` ... `complete` proximity rule cannot match).
520
+ */
521
+ export function formatUltragoalNudgeMessage(input: {
522
+ surface: UltragoalNudgeSurface;
523
+ attempt: number;
524
+ budget: number;
525
+ goalId: string;
526
+ }): string {
527
+ const label = NUDGE_SURFACE_LABEL[input.surface];
528
+ const reason = NUDGE_SURFACE_REASON[input.surface];
529
+ return [
530
+ `Ultragoal try-harder nudge (${input.attempt}/${input.budget}) for ${input.goalId}: ${label} was refused before the normal gate.`,
531
+ `Resolving this is part of the goal, not a reason to stop. Try a different approach first: inspect the failure, run a focused test or replay, find local credentials/config if access is the blocker, split the obstacle with \`gjc ultragoal steer --kind add_subgoal\`, delegate an executor, or record concrete review blockers.`,
532
+ `Reason: ${reason}.`,
533
+ ].join("\n");
534
+ }
535
+
536
+ /**
537
+ * Consume one nudge for a guarded give-up attempt. MUST only be called from assert/
538
+ * consume paths, never from read-style `is...Blocked` diagnostics. Returns a nudge
539
+ * message while the per-story budget remains; otherwise reports not-nudged so the
540
+ * caller falls through to today's gate.
541
+ */
542
+ async function consumeUltragoalNudge(input: {
543
+ cwd: string;
544
+ surface: UltragoalNudgeSurface;
545
+ currentGoal?: CurrentGoalLike | null;
546
+ sessionId?: string | null;
547
+ }): Promise<{ nudged: true; message: string } | { nudged: false }> {
548
+ const sessionId = input.sessionId?.trim() || (await ultragoalReadPaths(input.cwd)).sessionId;
549
+ if (!sessionId) return { nudged: false };
550
+ const plan = await readUltragoalPlan(input.cwd, sessionId);
551
+ if (!plan) return { nudged: false };
552
+ const target = selectUltragoalNudgeTarget(plan, { currentGoalObjective: input.currentGoal?.objective });
553
+ if (!target) return { nudged: false };
554
+ const { budget } = await resolveUltragoalNudgeBudget(input.cwd);
555
+ const outcome = await recordUltragoalNudgeIfBudgetRemaining({
556
+ cwd: input.cwd,
557
+ sessionId,
558
+ target,
559
+ surface: input.surface,
560
+ budget,
561
+ reason: NUDGE_SURFACE_REASON[input.surface],
562
+ ...(input.currentGoal?.objective ? { currentGoalObjective: input.currentGoal.objective } : {}),
563
+ });
564
+ if (outcome.nudged) {
565
+ return {
566
+ nudged: true,
567
+ message: formatUltragoalNudgeMessage({
568
+ surface: input.surface,
569
+ attempt: outcome.attempt,
570
+ budget: outcome.budget,
571
+ goalId: outcome.goalId,
572
+ }),
573
+ };
574
+ }
575
+ return { nudged: false };
576
+ }
577
+
578
+ /**
579
+ * Assert-path entry for the `ask` surface (the ask guard lives in another module).
580
+ * Resolves the active (leader) Ultragoal session so subagent/headless asks consume
581
+ * the leader run's budget rather than a fresh child ledger.
582
+ */
583
+ export async function consumeUltragoalAskNudge(
584
+ cwd: string,
585
+ sessionId?: string | null,
586
+ ): Promise<{ nudged: true; message: string } | { nudged: false }> {
587
+ if (!cwd) return { nudged: false };
588
+ return consumeUltragoalNudge({ cwd, surface: "ask", sessionId });
589
+ }
590
+
497
591
  export async function assertCanCompleteCurrentGoal(input: {
498
592
  cwd: string;
499
593
  currentGoal?: CurrentGoalLike | null;
@@ -502,6 +596,13 @@ export async function assertCanCompleteCurrentGoal(input: {
502
596
  if (!input.cwd) return;
503
597
  const diagnostic = await readUltragoalVerificationState(input);
504
598
  if (["inactive", "unrelated_goal", "active_verified_complete"].includes(diagnostic.state)) return;
599
+ const nudge = await consumeUltragoalNudge({
600
+ cwd: input.cwd,
601
+ surface: "premature_complete",
602
+ currentGoal: input.currentGoal,
603
+ sessionId: input.sessionId,
604
+ });
605
+ if (nudge.nudged) throw new Error(nudge.message);
505
606
  throw new Error(
506
607
  `${diagnostic.message} Run strict \`gjc ultragoal checkpoint --status complete --quality-gate-json <file> --gjc-goal-json <file>\` first, or record review blockers and rerun verification.`,
507
608
  );
@@ -558,6 +659,10 @@ export async function isUltragoalPauseBlocked(cwd: string): Promise<UltragoalPau
558
659
  }
559
660
 
560
661
  export async function assertUltragoalPauseAllowed(cwd: string): Promise<void> {
662
+ if (cwd) {
663
+ const nudge = await consumeUltragoalNudge({ cwd, surface: "pause" });
664
+ if (nudge.nudged) throw new Error(nudge.message);
665
+ }
561
666
  const diagnostic = await isUltragoalPauseBlocked(cwd);
562
667
  if (!diagnostic.blocked) return;
563
668
  throw new Error(
@@ -568,3 +673,65 @@ export async function assertUltragoalPauseAllowed(cwd: string): Promise<void> {
568
673
  ].join("\n"),
569
674
  );
570
675
  }
676
+ /**
677
+ * Guard `goal({"op":"drop"})` during an active Ultragoal run. A *real give-up* (an
678
+ * aggregate run still mid-flight with incomplete required stories) is nudged while
679
+ * budget remains; once exhausted it falls through to today's drop behavior. Legitimate
680
+ * aggregate-reset drops — no durable run, unrelated goal, an already dropped/stale
681
+ * aggregate, or an all-stories-complete run — are never nudged. If durable state
682
+ * exists but cannot be read to classify the drop, fail closed.
683
+ */
684
+ export async function assertUltragoalDropAllowed(input: {
685
+ cwd: string;
686
+ currentGoal?: CurrentGoalLike | null;
687
+ sessionId?: string | null;
688
+ }): Promise<void> {
689
+ if (!input.cwd) return;
690
+ let paths: UltragoalPaths;
691
+ let sessionId: string | null;
692
+ try {
693
+ ({ paths, sessionId } = await ultragoalReadPaths(input.cwd));
694
+ } catch (error) {
695
+ throw new Error(
696
+ `Unable to classify Ultragoal drop (durable state unreadable): ${error instanceof Error ? error.message : String(error)}`,
697
+ );
698
+ }
699
+ if (sessionId === null) return;
700
+ try {
701
+ await fs.stat(paths.dir);
702
+ } catch (error) {
703
+ if (isEnoent(error)) return;
704
+ throw new Error(
705
+ `Unable to classify Ultragoal drop (durable state present but unreadable): ${error instanceof Error ? error.message : String(error)}`,
706
+ );
707
+ }
708
+ let plan: UltragoalPlan | null;
709
+ try {
710
+ plan = await readUltragoalPlan(input.cwd, sessionId);
711
+ } catch (error) {
712
+ throw new Error(
713
+ `Unable to classify Ultragoal drop (goals.json unreadable): ${error instanceof Error ? error.message : String(error)}`,
714
+ );
715
+ }
716
+ if (!plan) {
717
+ throw new Error("Unable to classify Ultragoal drop: durable state exists but goals.json is missing or empty.");
718
+ }
719
+ // Out of scope: per-story mode drops keep today's behavior.
720
+ if (plan.gjcGoalMode !== "aggregate") return;
721
+ // A real give-up requires an active aggregate goal to actually be abandoned. With no
722
+ // current goal-mode goal (or a non-active one), `drop` is a no-op/reset before a fresh
723
+ // `create`, never a give-up — so it is left un-nudged.
724
+ if (!input.currentGoal) return;
725
+ if (input.currentGoal.status !== "active") return;
726
+ // Unrelated active goal: not this aggregate run.
727
+ if (!objectiveMatches(input.currentGoal.objective, plan)) return;
728
+ // All required stories complete: a legitimate reset, not a give-up.
729
+ if (getUltragoalRunCompletionState(plan).allComplete) return;
730
+ const nudge = await consumeUltragoalNudge({
731
+ cwd: input.cwd,
732
+ surface: "drop",
733
+ currentGoal: input.currentGoal,
734
+ sessionId,
735
+ });
736
+ if (nudge.nudged) throw new Error(nudge.message);
737
+ }