@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.
- package/CHANGELOG.md +54 -0
- package/README.md +73 -1
- package/dist/types/cli/update-cli.d.ts +3 -0
- package/dist/types/config/model-registry.d.ts +3 -0
- package/dist/types/config/models-config-schema.d.ts +5 -0
- package/dist/types/config/settings-schema.d.ts +27 -0
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +2 -0
- package/dist/types/lsp/startup-events.d.ts +1 -0
- package/dist/types/modes/components/welcome.d.ts +3 -1
- package/dist/types/modes/interactive-mode.d.ts +3 -0
- package/dist/types/modes/prompt-action-autocomplete.d.ts +1 -0
- package/dist/types/skill-state/deep-interview-mutation-guard.d.ts +5 -0
- package/package.json +7 -7
- package/scripts/build-binary.ts +0 -7
- package/src/cli/setup-cli.ts +14 -1
- package/src/cli/update-cli.ts +53 -3
- package/src/commands/launch.ts +1 -1
- package/src/config/model-registry.ts +9 -2
- package/src/config/model-resolver.ts +13 -2
- package/src/config/models-config-schema.ts +1 -0
- package/src/config/settings-schema.ts +17 -0
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +3 -1
- package/src/defaults/gjc/skills/ralplan/SKILL.md +2 -0
- package/src/exec/bash-executor.ts +3 -1
- package/src/gjc-runtime/launch-tmux.ts +62 -14
- package/src/gjc-runtime/state-runtime.ts +22 -14
- package/src/gjc-runtime/state-writer.ts +21 -1
- package/src/gjc-runtime/tmux-sessions.ts +36 -1
- package/src/internal-urls/docs-index.generated.ts +5 -6
- package/src/lsp/startup-events.ts +24 -0
- package/src/modes/components/welcome.ts +42 -9
- package/src/modes/controllers/input-controller.ts +21 -3
- package/src/modes/interactive-mode.ts +27 -19
- package/src/modes/prompt-action-autocomplete.ts +11 -1
- package/src/session/agent-session.ts +28 -20
- package/src/session/session-manager.ts +19 -2
- package/src/setup/hermes/templates/operator-instructions.v1.md +8 -0
- package/src/skill-state/active-state.ts +53 -30
- package/src/skill-state/deep-interview-mutation-guard.ts +238 -30
- package/src/slash-commands/builtin-registry.ts +8 -4
- package/src/system-prompt.ts +11 -9
- package/src/tools/ast-edit.ts +2 -2
- 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
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
389
|
-
const
|
|
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, () =>
|
|
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
|
-
|
|
828
|
-
|
|
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 {
|
|
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
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
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]
|
|
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 {
|
|
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 =
|
|
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
|
|
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.#
|
|
9903
|
+
const estimate = this.#estimateContextTokensWith(message => this.#estimateMessageCompactionDeltaTokens(message));
|
|
9905
9904
|
return {
|
|
9906
|
-
tokens: estimate.tokens + this.#
|
|
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
|
-
#
|
|
9951
|
+
#estimateMessagesCompactionDeltaTokens(messages: readonly AgentMessage[]): number {
|
|
9953
9952
|
let tokens = 0;
|
|
9954
9953
|
for (const message of messages) {
|
|
9955
|
-
tokens += this.#
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
9996
|
-
//
|
|
9997
|
-
//
|
|
9998
|
-
//
|
|
9999
|
-
//
|
|
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.#
|
|
10008
|
+
const cached = this.#compactionDeltaTokenCache.get(message);
|
|
10002
10009
|
if (cached && cached.len === len) return cached.tokens;
|
|
10003
|
-
let
|
|
10010
|
+
let heuristic = 0;
|
|
10004
10011
|
for (const llmMessage of convertToLlm([message])) {
|
|
10005
|
-
|
|
10012
|
+
heuristic += estimateMessageTokensHeuristic(llmMessage);
|
|
10006
10013
|
}
|
|
10007
|
-
|
|
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
|
|
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
|
|
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`.
|
|
563
|
-
*
|
|
564
|
-
*
|
|
565
|
-
*
|
|
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 excluded — it 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
|
-
|
|
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
|
|
622
|
-
.
|
|
623
|
-
|
|
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
|
}
|