@gajae-code/coding-agent 0.6.1 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +73 -1
  3. package/dist/types/cli/update-cli.d.ts +3 -0
  4. package/dist/types/config/model-registry.d.ts +3 -0
  5. package/dist/types/config/models-config-schema.d.ts +5 -0
  6. package/dist/types/config/settings-schema.d.ts +27 -0
  7. package/dist/types/gjc-runtime/tmux-sessions.d.ts +2 -0
  8. package/dist/types/lsp/startup-events.d.ts +1 -0
  9. package/dist/types/modes/components/welcome.d.ts +3 -1
  10. package/dist/types/modes/interactive-mode.d.ts +3 -0
  11. package/dist/types/modes/prompt-action-autocomplete.d.ts +1 -0
  12. package/dist/types/skill-state/deep-interview-mutation-guard.d.ts +5 -0
  13. package/package.json +7 -7
  14. package/scripts/build-binary.ts +0 -7
  15. package/src/cli/setup-cli.ts +14 -1
  16. package/src/cli/update-cli.ts +53 -3
  17. package/src/commands/launch.ts +1 -1
  18. package/src/config/model-registry.ts +9 -2
  19. package/src/config/model-resolver.ts +13 -2
  20. package/src/config/models-config-schema.ts +1 -0
  21. package/src/config/settings-schema.ts +17 -0
  22. package/src/defaults/gjc/skills/deep-interview/SKILL.md +3 -1
  23. package/src/defaults/gjc/skills/ralplan/SKILL.md +2 -0
  24. package/src/exec/bash-executor.ts +3 -1
  25. package/src/gjc-runtime/launch-tmux.ts +62 -14
  26. package/src/gjc-runtime/state-runtime.ts +22 -14
  27. package/src/gjc-runtime/state-writer.ts +21 -1
  28. package/src/gjc-runtime/tmux-sessions.ts +36 -1
  29. package/src/internal-urls/docs-index.generated.ts +5 -6
  30. package/src/lsp/startup-events.ts +24 -0
  31. package/src/modes/components/welcome.ts +42 -9
  32. package/src/modes/controllers/input-controller.ts +21 -3
  33. package/src/modes/interactive-mode.ts +27 -19
  34. package/src/modes/prompt-action-autocomplete.ts +11 -1
  35. package/src/session/agent-session.ts +28 -20
  36. package/src/session/session-manager.ts +19 -2
  37. package/src/setup/hermes/templates/operator-instructions.v1.md +8 -0
  38. package/src/skill-state/active-state.ts +53 -30
  39. package/src/skill-state/deep-interview-mutation-guard.ts +238 -30
  40. package/src/slash-commands/builtin-registry.ts +8 -4
  41. package/src/system-prompt.ts +11 -9
  42. package/src/tools/ast-edit.ts +2 -2
  43. package/src/utils/edit-mode.ts +1 -1
@@ -11,3 +11,27 @@ export type LspStartupEvent =
11
11
  type: "failed";
12
12
  error: string;
13
13
  };
14
+ const OPTIONAL_STARTUP_FAILURE_SERVERS = new Set(["rust-analyzer"]);
15
+
16
+ function isOptionalStartupFailure(server: LspStartupServerInfo): boolean {
17
+ return server.status === "error" && OPTIONAL_STARTUP_FAILURE_SERVERS.has(server.name);
18
+ }
19
+
20
+ export function getLspStartupWarningMessage(event: LspStartupEvent): string | null {
21
+ if (event.type === "failed") {
22
+ return "LSP startup failed. It will retry lazily on write.";
23
+ }
24
+
25
+ const failedServers = event.servers.filter(server => server.status === "error" && !isOptionalStartupFailure(server));
26
+
27
+ if (failedServers.length === 1) {
28
+ return `LSP startup failed for ${failedServers[0].name}. It will retry lazily on write.`;
29
+ }
30
+
31
+ if (failedServers.length > 1) {
32
+ const failedNames = failedServers.map(server => server.name).join(", ");
33
+ return `LSP startup failed for ${failedNames}. It will retry lazily on write.`;
34
+ }
35
+
36
+ return null;
37
+ }
@@ -13,6 +13,8 @@ export interface LspServerInfo {
13
13
  fileTypes: string[];
14
14
  }
15
15
 
16
+ export type WelcomeLogoMode = "unicode" | "square" | "ascii";
17
+
16
18
  /**
17
19
  * GJC-native launch surface with compact command affordances, project
18
20
  * signals, and a claw/talon mark without copying another agent shell.
@@ -27,6 +29,7 @@ export class WelcomeComponent implements Component {
27
29
  private providerName: string,
28
30
  private recentSessions: RecentSession[] = [],
29
31
  private lspServers: LspServerInfo[] = [],
32
+ private readonly logoMode: WelcomeLogoMode = "unicode",
30
33
  ) {}
31
34
 
32
35
  invalidate(): void {}
@@ -83,7 +86,8 @@ export class WelcomeComponent implements Component {
83
86
  const minRightCol = 20;
84
87
  const modelPill = this.#pill(theme.icon.model || "model", this.modelName, "statusLineModel");
85
88
  const providerPill = this.#pill(theme.icon.package || "provider", this.providerName, "statusLinePath");
86
- const logoMinWidth = Math.max(...RED_CLAW_LOGO.map(line => visibleWidth(line)));
89
+ const logoLines = this.#logoLines();
90
+ const logoMinWidth = Math.max(...logoLines.map(line => visibleWidth(line)));
87
91
  const leftMinContentWidth = Math.max(
88
92
  minLeftCol,
89
93
  logoMinWidth,
@@ -102,8 +106,7 @@ export class WelcomeComponent implements Component {
102
106
  const leftCol = showRightColumn ? dualLeftCol : boxWidth - 2;
103
107
  const rightCol = showRightColumn ? dualRightCol : 0;
104
108
 
105
- // Logo: pick a frame from the intro animation if active, else the resting frame.
106
- const logoColored = this.#currentLogoFrame();
109
+ const logoColored = this.#currentLogoFrame(logoLines);
107
110
 
108
111
  // Left column - centered content
109
112
  const leftLines = [
@@ -258,10 +261,10 @@ export class WelcomeComponent implements Component {
258
261
  }
259
262
 
260
263
  /** Pick the logo frame for the current intro phase, or the resting frame. */
261
- #currentLogoFrame(): readonly string[] {
262
- if (this.#animStart == null) return REST_FRAME;
264
+ #currentLogoFrame(logoLines: readonly string[]): readonly string[] {
265
+ if (this.#animStart == null) return REST_FRAMES[this.logoMode];
263
266
  const elapsed = performance.now() - this.#animStart;
264
- if (elapsed >= INTRO_MS) return REST_FRAME;
267
+ if (elapsed >= INTRO_MS) return REST_FRAMES[this.logoMode];
265
268
  // Ease-out cubic so the spin decelerates into the resting state.
266
269
  const progress = elapsed / INTRO_MS;
267
270
  const eased = 1 - (1 - progress) ** 3;
@@ -273,7 +276,13 @@ export class WelcomeComponent implements Component {
273
276
  // the same ease-out curve so the highlight is gone by the resting frame.
274
277
  const shinePos = (((progress * INTRO_SHINE_TRAVERSALS) % 1) + 1) % 1;
275
278
  const shineStrength = (1 - eased) ** 1.5;
276
- return gradientLogo(RED_CLAW_LOGO, phase, { strength: shineStrength, pos: shinePos });
279
+ return gradientLogo(logoLines, phase, { strength: shineStrength, pos: shinePos });
280
+ }
281
+
282
+ #logoLines(): readonly string[] {
283
+ if (this.logoMode === "ascii") return ASCII_CLAW_LOGO;
284
+ if (this.logoMode === "square") return SQUARE_CLAW_LOGO;
285
+ return RED_CLAW_LOGO;
277
286
  }
278
287
  }
279
288
 
@@ -287,6 +296,26 @@ const RED_CLAW_LOGO = [
287
296
  "╰────────────────╯ ╰────────╯",
288
297
  ];
289
298
 
299
+ // biome-ignore format: preserve ASCII art layout
300
+ const SQUARE_CLAW_LOGO = [
301
+ "┌────────────────┐ ┌────────┐",
302
+ "└──────┐ ┌──┘ ┌──┘ ┌─────┘",
303
+ " └──────┘ ┌───┘ ┌──┘ ",
304
+ " ┌──────┐ └───┐ └──┐ ",
305
+ "┌──────┘ └──┐ └──┐ └─────┐",
306
+ "└────────────────┘ └────────┘",
307
+ ];
308
+
309
+ // biome-ignore format: preserve ASCII art layout
310
+ const ASCII_CLAW_LOGO = [
311
+ "+----------------+ +--------+",
312
+ "+------+ +--+ +--+ +-----+",
313
+ " +------+ +---+ +--+ ",
314
+ " +------+ +---+ +--+ ",
315
+ "+------+ +--+ +--+ +-----+",
316
+ "+----------------+ +--------+",
317
+ ];
318
+
290
319
  /** Multi-stop palette for the red-claw diagonal gradient. */
291
320
  const GRADIENT_STOPS: ReadonlyArray<readonly [number, number, number]> = [
292
321
  [127, 29, 29], // deep shell red
@@ -385,5 +414,9 @@ const INTRO_SWEEPS = 2.5;
385
414
  /** Number of times the shine highlight crosses the diagonal across the intro. */
386
415
  const INTRO_SHINE_TRAVERSALS = 3;
387
416
 
388
- /** Resting gradient frame, cached for re-renders outside of the intro. */
389
- const REST_FRAME = gradientLogo(RED_CLAW_LOGO, 0);
417
+ /** Resting gradient frames, cached for re-renders outside of the intro. */
418
+ const REST_FRAMES: Record<WelcomeLogoMode, readonly string[]> = {
419
+ unicode: gradientLogo(RED_CLAW_LOGO, 0),
420
+ square: gradientLogo(SQUARE_CLAW_LOGO, 0),
421
+ ascii: gradientLogo(ASCII_CLAW_LOGO, 0),
422
+ };
@@ -199,6 +199,9 @@ export class InputController {
199
199
  this.ctx.editor.onDequeue = () => this.handleDequeue();
200
200
  this.ctx.editor.setActionKeys("app.message.queue", this.ctx.keybindings.getKeys("app.message.queue"));
201
201
  this.ctx.editor.onQueue = () => void this.handleQueueSubmit();
202
+ this.ctx.editor.onTabDeclined = () => {
203
+ if (this.ctx.session.isStreaming) void this.handleQueueSubmit();
204
+ };
202
205
 
203
206
  this.ctx.editor.clearCustomKeyHandlers();
204
207
  // Wire up extension shortcuts
@@ -228,7 +231,11 @@ export class InputController {
228
231
  });
229
232
  }
230
233
  for (const key of this.ctx.keybindings.getKeys("app.message.followUp")) {
231
- this.ctx.editor.setCustomKeyHandler(key, () => void this.handleFollowUp());
234
+ this.ctx.editor.setCustomKeyHandler(key, () => {
235
+ if (!this.#isFollowUpShortcutActive()) return false;
236
+ void this.handleFollowUp();
237
+ return true;
238
+ });
232
239
  }
233
240
  for (const key of this.ctx.keybindings.getKeys("app.stt.toggle")) {
234
241
  this.ctx.editor.setCustomKeyHandler(key, () => void this.ctx.handleSTTToggle());
@@ -503,6 +510,15 @@ export class InputController {
503
510
  return this.ctx.settings.get("busyPromptMode") === "steer" ? "steer" : "followUp";
504
511
  }
505
512
 
513
+ #isFollowUpShortcutActive(): boolean {
514
+ return (
515
+ this.ctx.session.isStreaming ||
516
+ this.ctx.session.isCompacting ||
517
+ this.ctx.session.isBashRunning ||
518
+ this.ctx.session.isEvalRunning
519
+ );
520
+ }
521
+
506
522
  /**
507
523
  * Dispatch skill slash invocation(s) (`/skill:<name>`) through custom messages
508
524
  * using the supplied `streamingBehavior`. Returns true if the text was a
@@ -824,8 +840,9 @@ export class InputController {
824
840
  this.ctx.ui.requestRender();
825
841
  return true;
826
842
  }
827
- // No image in clipboard - show hint
828
- this.ctx.showStatus("No image in clipboard (use terminal paste for text)");
843
+ this.ctx.showStatus(
844
+ "No image in clipboard. Use #paste-image, paste a copied image, or attach an image file with @path/to/image.png.",
845
+ );
829
846
  return false;
830
847
  } catch {
831
848
  this.ctx.showStatus("Failed to read clipboard");
@@ -840,6 +857,7 @@ export class InputController {
840
857
  keybindings: this.ctx.keybindings,
841
858
  copyCurrentLine: () => this.handleCopyCurrentLine(),
842
859
  copyPrompt: () => this.handleCopyPrompt(),
860
+ pasteImage: () => void this.handleImagePaste(),
843
861
  undo: prefix => this.ctx.editor.undoPastTransientText(prefix),
844
862
  moveCursorToMessageEnd: () => this.ctx.editor.moveToMessageEnd(),
845
863
  moveCursorToMessageStart: () => this.ctx.editor.moveToMessageStart(),
@@ -44,7 +44,7 @@ import { BUILTIN_SLASH_COMMANDS, loadSlashCommands } from "../extensibility/slas
44
44
  import { consumePendingGoalModeRequest } from "../gjc-runtime/goal-mode-request";
45
45
  import { type Goal, type GoalModeState, normalizeGoal } from "../goals/state";
46
46
  import { resolveLocalUrlToPath } from "../internal-urls";
47
- import { LSP_STARTUP_EVENT_CHANNEL, type LspStartupEvent } from "../lsp/startup-events";
47
+ import { getLspStartupWarningMessage, LSP_STARTUP_EVENT_CHANNEL, type LspStartupEvent } from "../lsp/startup-events";
48
48
  import {
49
49
  humanizePlanTitle,
50
50
  type PlanApprovalDetails,
@@ -71,6 +71,7 @@ import { normalizeLocalScheme } from "../tools/path-utils";
71
71
  import { type ResolveToolDetails, runResolveInvocation } from "../tools/resolve";
72
72
  import { formatPhaseDisplayName } from "../tools/todo-write";
73
73
  import { ToolError } from "../tools/tool-errors";
74
+
74
75
  import type { EventBus } from "../utils/event-bus";
75
76
  import { getEditorCommand, openInEditor } from "../utils/external-editor";
76
77
  import { getSessionAccentAnsi, getSessionAccentHex } from "../utils/session-color";
@@ -85,7 +86,11 @@ import type { HookInputComponent } from "./components/hook-input";
85
86
  import type { HookSelectorComponent } from "./components/hook-selector";
86
87
  import { StatusLineComponent } from "./components/status-line";
87
88
  import type { ToolExecutionHandle } from "./components/tool-execution";
88
- import { WelcomeComponent, type LspServerInfo as WelcomeLspServerInfo } from "./components/welcome";
89
+ import {
90
+ WelcomeComponent,
91
+ type WelcomeLogoMode,
92
+ type LspServerInfo as WelcomeLspServerInfo,
93
+ } from "./components/welcome";
89
94
  import { BtwController } from "./controllers/btw-controller";
90
95
  import { CommandController } from "./controllers/command-controller";
91
96
  import { EventController } from "./controllers/event-controller";
@@ -217,6 +222,21 @@ function parseGoalSubcommand(args: string): { sub: GoalSubcommand | undefined; r
217
222
  return { sub: undefined, rest: trimmed };
218
223
  }
219
224
 
225
+ export type WelcomeBannerSettingMode = "auto" | "unicode" | "square" | "ascii";
226
+
227
+ export function resolveWelcomeLogoMode(
228
+ mode: WelcomeBannerSettingMode,
229
+ env: Record<string, string | undefined> = Bun.env,
230
+ platform: NodeJS.Platform = process.platform,
231
+ ): WelcomeLogoMode {
232
+ void env;
233
+ void platform;
234
+ if (mode === "unicode") return "unicode";
235
+ if (mode === "square") return "square";
236
+ if (mode === "ascii") return "ascii";
237
+ return "unicode";
238
+ }
239
+
220
240
  /** Options for creating an InteractiveMode instance (for future API use) */
221
241
  export interface InteractiveModeOptions {
222
242
  /** Providers that were migrated during startup */
@@ -478,6 +498,7 @@ export class InteractiveMode implements InteractiveModeContext {
478
498
  );
479
499
 
480
500
  const startupQuiet = settings.get("startup.quiet");
501
+ const welcomeLogoMode = resolveWelcomeLogoMode(settings.get("startup.welcomeBannerMode"));
481
502
  this.#welcomeComponent = undefined;
482
503
 
483
504
  for (const warning of this.session.configWarnings) {
@@ -493,6 +514,7 @@ export class InteractiveMode implements InteractiveModeContext {
493
514
  providerName,
494
515
  recentSessions,
495
516
  this.#getWelcomeLspServers(),
517
+ welcomeLogoMode,
496
518
  );
497
519
 
498
520
  // Setup UI layout
@@ -2061,23 +2083,9 @@ export class InteractiveMode implements InteractiveModeContext {
2061
2083
  #handleLspStartupEvent(event: LspStartupEvent): void {
2062
2084
  this.#updateWelcomeLspServers();
2063
2085
 
2064
- if (event.type === "failed") {
2065
- this.showWarning(`LSP startup failed: ${event.error}. It will retry lazily on write.`);
2066
- return;
2067
- }
2068
-
2069
- const failedServers = event.servers.filter(server => server.status === "error");
2070
-
2071
- if (failedServers.length === 1) {
2072
- const failedServer = failedServers[0];
2073
- const detail = failedServer.error ? `: ${failedServer.error}` : "";
2074
- this.showWarning(`LSP startup failed for ${failedServer.name}${detail}. It will retry lazily on write.`);
2075
- return;
2076
- }
2077
-
2078
- if (failedServers.length > 1) {
2079
- const failedNames = failedServers.map(server => server.name).join(", ");
2080
- this.showWarning(`LSP startup failed for ${failedNames}. It will retry lazily on write.`);
2086
+ const warningMessage = getLspStartupWarningMessage(event);
2087
+ if (warningMessage) {
2088
+ this.showWarning(warningMessage);
2081
2089
  }
2082
2090
  }
2083
2091
 
@@ -28,6 +28,7 @@ interface PromptActionAutocompleteOptions {
28
28
  keybindings: KeybindingsManager;
29
29
  copyCurrentLine: () => void;
30
30
  copyPrompt: () => void;
31
+ pasteImage: () => void;
31
32
  undo: (prefix: string) => void;
32
33
  moveCursorToMessageEnd: () => void;
33
34
  moveCursorToMessageStart: () => void;
@@ -190,7 +191,9 @@ export class PromptActionAutocompleteProvider implements AutocompleteProvider {
190
191
  const query = promptActionPrefix.slice(1).toLowerCase();
191
192
  const items = this.#actions
192
193
  .map(action => {
193
- const searchable = [action.label, action.description, ...action.keywords].join(" ").toLowerCase();
194
+ const searchable = [action.id, action.label, action.description, ...action.keywords]
195
+ .join(" ")
196
+ .toLowerCase();
194
197
  if (!fuzzyMatch(query, searchable)) return null;
195
198
  return {
196
199
  value: action.label,
@@ -368,6 +371,13 @@ export function createPromptActionAutocompleteProvider(
368
371
  keywords: ["copy", "prompt", "clipboard", "message"],
369
372
  execute: options.copyPrompt,
370
373
  },
374
+ {
375
+ id: "paste-image",
376
+ label: "Paste image from clipboard",
377
+ description: formatKeyHints(options.keybindings.getKeys("app.clipboard.pasteImage")),
378
+ keywords: ["paste", "image", "clipboard", "screenshot", "attach", "vision"],
379
+ execute: options.pasteImage,
380
+ },
371
381
  {
372
382
  id: "undo",
373
383
  label: "Undo",
@@ -44,7 +44,6 @@ import {
44
44
  type EmergencyCompactionSample,
45
45
  emergencyCompactionReason,
46
46
  estimateMessageTokensHeuristic,
47
- estimateTokens,
48
47
  generateBranchSummary,
49
48
  generateHandoff,
50
49
  prepareCompaction,
@@ -225,7 +224,7 @@ import {
225
224
  readVisibleSkillActiveState,
226
225
  syncSkillActiveState,
227
226
  } from "../skill-state/active-state";
228
- import { assertDeepInterviewMutationAllowed } from "../skill-state/deep-interview-mutation-guard";
227
+ import { assertWorkflowMutationAllowed } from "../skill-state/deep-interview-mutation-guard";
229
228
  import { invalidateHostMetadata } from "../ssh/connection-manager";
230
229
  import { resolveThinkingLevelForModel, toReasoningEffort } from "../thinking";
231
230
  import {
@@ -1475,7 +1474,7 @@ export class AgentSession {
1475
1474
  }
1476
1475
  const sanitized = sanitizeMessage(providerMessages[i]!);
1477
1476
  if (!sanitized) continue;
1478
- const messageTokens = estimateTokens(sanitized);
1477
+ const messageTokens = estimateMessageTokensHeuristic(sanitized);
1479
1478
  if (maxTokens > 0 && approximateTokens + messageTokens > maxTokens) {
1480
1479
  recordSkip("token-limit");
1481
1480
  continue;
@@ -3775,7 +3774,7 @@ export class AgentSession {
3775
3774
  onUpdate: never,
3776
3775
  ctx: never,
3777
3776
  ) => {
3778
- await assertDeepInterviewMutationAllowed({
3777
+ await assertWorkflowMutationAllowed({
3779
3778
  cwd: this.sessionManager.getCwd(),
3780
3779
  sessionId: this.sessionManager.getSessionId(),
3781
3780
  tool: target,
@@ -9901,9 +9900,9 @@ export class AgentSession {
9901
9900
  #estimateContextTokensForCompaction(pendingMessages: readonly AgentMessage[]): {
9902
9901
  tokens: number;
9903
9902
  } {
9904
- const estimate = this.#estimateContextTokensWith(message => this.#estimateMessageNativeContextTokens(message));
9903
+ const estimate = this.#estimateContextTokensWith(message => this.#estimateMessageCompactionDeltaTokens(message));
9905
9904
  return {
9906
- tokens: estimate.tokens + this.#estimateMessagesNativeContextTokens(pendingMessages),
9905
+ tokens: estimate.tokens + this.#estimateMessagesCompactionDeltaTokens(pendingMessages),
9907
9906
  };
9908
9907
  }
9909
9908
 
@@ -9949,10 +9948,10 @@ export class AgentSession {
9949
9948
  };
9950
9949
  }
9951
9950
 
9952
- #estimateMessagesNativeContextTokens(messages: readonly AgentMessage[]): number {
9951
+ #estimateMessagesCompactionDeltaTokens(messages: readonly AgentMessage[]): number {
9953
9952
  let tokens = 0;
9954
9953
  for (const message of messages) {
9955
- tokens += this.#estimateMessageNativeContextTokens(message);
9954
+ tokens += this.#estimateMessageCompactionDeltaTokens(message);
9956
9955
  }
9957
9956
  return tokens;
9958
9957
  }
@@ -9965,11 +9964,17 @@ export class AgentSession {
9965
9964
  return tokens;
9966
9965
  }
9967
9966
 
9968
- #nativeTokenCache = new WeakMap<AgentMessage, { len: number; tokens: number }>();
9967
+ /**
9968
+ * Conservative inflation applied to the native-free chars/4 estimate of the
9969
+ * UNSENT context delta. chars/4 undercounts dense code/CJK, so we bias high
9970
+ * to compact slightly early rather than overflow the model window before the
9971
+ * next provider response re-anchors the exact count.
9972
+ */
9973
+ #compactionDeltaInflation = 1.2;
9974
+ #compactionDeltaTokenCache = new WeakMap<AgentMessage, { len: number; tokens: number }>();
9969
9975
 
9970
- /** Cheap content-size signal to invalidate the native token cache on mutation (growth). */
9971
9976
  /**
9972
- * Cheap content-size signal to invalidate the native token cache on mutation. Recursively
9977
+ * Cheap content-size signal to invalidate the compaction-delta token cache on mutation. Recursively
9973
9978
  * sums string lengths across the whole message (depth-bounded), so it covers every
9974
9979
  * provider-visible shape (text/thinking/tool args, toolResult output, tool names, etc.)
9975
9980
  * without allocating a serialized copy. A size-preserving in-place edit yields only a
@@ -9992,19 +9997,22 @@ export class AgentSession {
9992
9997
  return 0;
9993
9998
  }
9994
9999
 
9995
- #estimateMessageNativeContextTokens(message: AgentMessage): number {
9996
- // F10/F22: cache the expensive native token count per message object, invalidated by a
9997
- // cheap content-size signal, so unchanged (stable-size) messages are not re-tokenized on
9998
- // every pre-prompt estimate. A rare size-preserving in-place edit yields only a benign
9999
- // token-estimate drift, never wrong output.
10000
+ #estimateMessageCompactionDeltaTokens(message: AgentMessage): number {
10001
+ // Provider usage anchors the already-sent context (see calculatePromptTokens); this
10002
+ // estimates only the UNSENT delta with the native-free chars/4 heuristic, inflated by
10003
+ // #compactionDeltaInflation so dense input cannot undercount us past the compaction
10004
+ // threshold before the next provider response re-anchors the exact count. Cached per
10005
+ // message object, invalidated by a cheap content-size signal; a rare size-preserving
10006
+ // in-place edit yields only a benign estimate drift, never wrong output.
10000
10007
  const len = this.#messageTokenSize(message);
10001
- const cached = this.#nativeTokenCache.get(message);
10008
+ const cached = this.#compactionDeltaTokenCache.get(message);
10002
10009
  if (cached && cached.len === len) return cached.tokens;
10003
- let tokens = 0;
10010
+ let heuristic = 0;
10004
10011
  for (const llmMessage of convertToLlm([message])) {
10005
- tokens += estimateTokens(llmMessage);
10012
+ heuristic += estimateMessageTokensHeuristic(llmMessage);
10006
10013
  }
10007
- this.#nativeTokenCache.set(message, { len, tokens });
10014
+ const tokens = Math.ceil(heuristic * this.#compactionDeltaInflation);
10015
+ this.#compactionDeltaTokenCache.set(message, { len, tokens });
10008
10016
  return tokens;
10009
10017
  }
10010
10018
 
@@ -1129,6 +1129,23 @@ function formatTimeAgo(date: Date): string {
1129
1129
  return date.toLocaleDateString();
1130
1130
  }
1131
1131
 
1132
+ async function movePathAcrossDevicesSafe(source: string, destination: string): Promise<void> {
1133
+ try {
1134
+ await fs.promises.rename(source, destination);
1135
+ return;
1136
+ } catch (error) {
1137
+ if (!hasFsCode(error, "EXDEV")) throw error;
1138
+ }
1139
+ const stat = await fs.promises.stat(source);
1140
+ if (stat.isDirectory()) {
1141
+ await fs.promises.cp(source, destination, { recursive: true, force: false, errorOnExist: true });
1142
+ await fs.promises.rm(source, { recursive: true, force: false });
1143
+ return;
1144
+ }
1145
+ await fs.promises.copyFile(source, destination, fs.constants.COPYFILE_EXCL);
1146
+ await fs.promises.unlink(source);
1147
+ }
1148
+
1132
1149
  const MAX_PERSIST_CHARS = 500_000;
1133
1150
  const TRUNCATION_NOTICE = "\n\n[Session persistence truncated large content]";
1134
1151
  /** Minimum base64 length to externalize to blob store (skip tiny inline images) */
@@ -2498,14 +2515,14 @@ export class SessionManager {
2498
2515
  try {
2499
2516
  // Guard: session file may not exist yet (no assistant messages persisted)
2500
2517
  if (hadSessionFile) {
2501
- await fs.promises.rename(oldSessionFile, newSessionFile);
2518
+ await movePathAcrossDevicesSafe(oldSessionFile, newSessionFile);
2502
2519
  movedSessionFile = true;
2503
2520
  }
2504
2521
 
2505
2522
  try {
2506
2523
  const stat = await fs.promises.stat(oldArtifactDir);
2507
2524
  if (stat.isDirectory()) {
2508
- await fs.promises.rename(oldArtifactDir, newArtifactDir);
2525
+ await movePathAcrossDevicesSafe(oldArtifactDir, newArtifactDir);
2509
2526
  movedArtifactDir = true;
2510
2527
  }
2511
2528
  } catch (err) {
@@ -29,6 +29,14 @@ The Hermes bridge does not choose a model/provider. Generated setup configures `
29
29
 
30
30
  Provider-specific commands are examples only, never product defaults.
31
31
 
32
+ ## Visible routed-session fallback
33
+
34
+ If a Hermes/OpenClaw/Clawhip-style operator needs a human-visible, channel-routed GJC pane instead of a pure Coordinator MCP session, use the visible session pattern in [`docs/gjc-session-clawhip-routing.md`](../../../../../../docs/gjc-session-clawhip-routing.md).
35
+
36
+ Use that pattern only when the router must watch tmux output, send stale-session alerts, or inject follow-up prompts into the same visible pane. The short version is: prepare a dedicated worktree, register a stable tmux session through the host router, start interactive `gjc`, wait for TUI readiness, inject the task prompt separately, and verify actual tool/work activity before reporting acceptance.
37
+
38
+ Do not put private channel ids, mention targets, socket names, tokens, or local routing policy into portable setup output. Keep those in the host/operator deployment.
39
+
32
40
  ## Safety
33
41
 
34
42
  - Mutating tools require bridge startup mutation classes and per-call consent.
@@ -559,39 +559,44 @@ function dedupeVisibleBySkill(entries: SkillActiveEntry[], sessionId?: string):
559
559
 
560
560
  /**
561
561
  * The planning pipeline advances one stage at a time: `deep-interview →
562
- * ralplan → ultragoal`. Each stage is activated through its own command path
563
- * (`gjc deep-interview`, `gjc ralplan`, `gjc ultragoal`), and those activations
564
- * do not demote the previous stage's row only the explicit `handoff` verb
565
- * does. Without this collapse, activating ultragoal while ralplan is still
566
- * `active:true` would render both stages and keep showing a workflow that has
567
- * already handed control forward. Keep only the most recently updated pipeline
568
- * stage so the HUD reflects the single current workflow. `team` is intentionally
569
- * excluded — it runs alongside ultragoal — and every non-pipeline skill is left
570
- * untouched.
571
- *
572
- * This is a HUD-display policy only. It is applied by the skill HUD renderer and
573
- * deliberately NOT folded into `readVisibleSkillActiveState`, whose callers (the
574
- * deep-interview mutation guard and handoff caller inference) must keep seeing
575
- * every genuinely-active skill rather than the single most-recent pipeline stage.
562
+ * ralplan → ultragoal`. Activating a downstream stage supersedes upstream
563
+ * stages so stale rows cannot keep owning the HUD, gate, or primary active
564
+ * snapshot. `team` is intentionally excludedit runs alongside ultragoal
565
+ * and every non-pipeline skill is left untouched.
576
566
  */
577
567
  const PLANNING_PIPELINE_SKILLS = new Set<string>(["deep-interview", "ralplan", "ultragoal"]);
568
+ const PLANNING_PIPELINE_RANK = new Map<string, number>([
569
+ ["deep-interview", 0],
570
+ ["ralplan", 1],
571
+ ["ultragoal", 2],
572
+ ]);
573
+
574
+ function planningPipelineRank(skill: string): number | undefined {
575
+ return PLANNING_PIPELINE_RANK.get(skill);
576
+ }
577
+
578
+ function comparePipelineEntry(a: SkillActiveEntry, b: SkillActiveEntry): number {
579
+ const aRank = planningPipelineRank(a.skill);
580
+ const bRank = planningPipelineRank(b.skill);
581
+ if (aRank !== undefined || bRank !== undefined) return (bRank ?? -1) - (aRank ?? -1);
582
+ const aRecency = entryRecency(a);
583
+ const bRecency = entryRecency(b);
584
+ if (Number.isFinite(aRecency) || Number.isFinite(bRecency)) return (bRecency || 0) - (aRecency || 0);
585
+ return 0;
586
+ }
587
+
588
+ function upstreamPlanningPipelineSkills(skill: string): string[] {
589
+ const rank = planningPipelineRank(skill);
590
+ if (rank === undefined) return [];
591
+ return [...PLANNING_PIPELINE_RANK.entries()]
592
+ .filter(([, candidateRank]) => candidateRank < rank)
593
+ .map(([candidate]) => candidate);
594
+ }
578
595
 
579
596
  export function collapsePlanningPipeline(entries: readonly SkillActiveEntry[]): SkillActiveEntry[] {
580
597
  const pipeline = entries.filter(entry => PLANNING_PIPELINE_SKILLS.has(entry.skill));
581
598
  if (pipeline.length <= 1) return [...entries];
582
- let current = pipeline[0];
583
- let currentRecency = entryRecency(current);
584
- for (const entry of pipeline) {
585
- const recency = entryRecency(entry);
586
- // Prefer a strictly-newer valid timestamp; a valid timestamp also beats a
587
- // missing/unparseable one. Ties (or all-invalid) keep the first stage
588
- // deterministically rather than letting an unknown-recency row win.
589
- const better = Number.isFinite(recency) && (!Number.isFinite(currentRecency) || recency > currentRecency);
590
- if (better) {
591
- current = entry;
592
- currentRecency = recency;
593
- }
594
- }
599
+ const current = pipeline.toSorted(comparePipelineEntry)[0];
595
600
  return entries.filter(entry => !PLANNING_PIPELINE_SKILLS.has(entry.skill) || entry === current);
596
601
  }
597
602
 
@@ -618,9 +623,11 @@ async function mergeVisibleEntries(
618
623
  merged.set(entryKey(entry), entry);
619
624
  }
620
625
  const canonicalRalplanPhase = await readModeStatePhase(cwd, sessionId, "ralplan");
621
- return dedupeVisibleBySkill([...merged.values()], sessionId)
622
- .filter(entry => entry.active !== false)
623
- .map(entry => withCanonicalRalplanPhase(entry, canonicalRalplanPhase));
626
+ return collapsePlanningPipeline(
627
+ dedupeVisibleBySkill([...merged.values()], sessionId)
628
+ .filter(entry => entry.active !== false)
629
+ .map(entry => withCanonicalRalplanPhase(entry, canonicalRalplanPhase)),
630
+ );
624
631
  }
625
632
 
626
633
  export async function readVisibleSkillActiveState(cwd: string, sessionId?: string): Promise<SkillActiveState | null> {
@@ -682,6 +689,20 @@ async function rebuildActiveState(cwd: string, sessionScope?: ActiveSessionScope
682
689
  await rebuildActiveSnapshot(cwd, sessionScope, { cwd, audit: activeStateWriterAudit("rebuild-active-snapshot") });
683
690
  }
684
691
 
692
+ async function removeSupersededPlanningPipelineEntries(
693
+ cwd: string,
694
+ sessionScope: ActiveSessionScope | undefined,
695
+ entry: SkillActiveEntry,
696
+ ): Promise<void> {
697
+ if (entry.active === false) return;
698
+ for (const skill of upstreamPlanningPipelineSkills(entry.skill)) {
699
+ await removeActiveEntry(cwd, sessionScope, skill, {
700
+ cwd,
701
+ audit: activeStateWriterAudit("remove-superseded-pipeline-entry"),
702
+ });
703
+ }
704
+ }
705
+
685
706
  async function activeSubskillsForExistingEntry(
686
707
  cwd: string,
687
708
  sessionId: string | undefined,
@@ -725,11 +746,13 @@ export async function syncSkillActiveState(options: SyncSkillActiveStateOptions)
725
746
  ? { active_subskills: preservedActiveSubskills }
726
747
  : {}),
727
748
  };
749
+ await removeSupersededPlanningPipelineEntries(options.cwd, undefined, entry);
728
750
  await persistActiveEntry(options.cwd, undefined, entry);
729
751
  await rebuildActiveState(options.cwd);
730
752
 
731
753
  if (!options.sessionId) return;
732
754
  const sessionScope = { sessionId: options.sessionId };
755
+ await removeSupersededPlanningPipelineEntries(options.cwd, sessionScope, entry);
733
756
  await persistActiveEntry(options.cwd, sessionScope, entry);
734
757
  await rebuildActiveState(options.cwd, sessionScope);
735
758
  }