@gajae-code/coding-agent 0.4.4 → 0.4.5

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 (68) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/types/cli/fast-help.d.ts +1 -0
  3. package/dist/types/cli/setup-cli.d.ts +2 -0
  4. package/dist/types/commands/harness.d.ts +3 -0
  5. package/dist/types/commands/setup.d.ts +6 -0
  6. package/dist/types/config/model-registry.d.ts +3 -0
  7. package/dist/types/config/models-config-schema.d.ts +5 -0
  8. package/dist/types/coordinator/contract.d.ts +1 -1
  9. package/dist/types/coordinator-mcp/server.d.ts +8 -2
  10. package/dist/types/harness-control-plane/finalize.d.ts +5 -0
  11. package/dist/types/harness-control-plane/phase-rollup.d.ts +23 -0
  12. package/dist/types/harness-control-plane/receipt-ingest.d.ts +19 -0
  13. package/dist/types/harness-control-plane/receipts.d.ts +46 -0
  14. package/dist/types/harness-control-plane/rpc-adapter.d.ts +3 -0
  15. package/dist/types/harness-control-plane/types.d.ts +9 -1
  16. package/dist/types/main.d.ts +2 -2
  17. package/dist/types/modes/utils/abort-message.d.ts +4 -0
  18. package/dist/types/session/session-manager.d.ts +8 -0
  19. package/dist/types/setup/hermes-setup.d.ts +7 -0
  20. package/dist/types/task/fork-context-advisory.d.ts +13 -0
  21. package/dist/types/task/receipt.d.ts +1 -0
  22. package/dist/types/task/roi-reconciliation.d.ts +27 -0
  23. package/dist/types/task/types.d.ts +10 -0
  24. package/package.json +8 -7
  25. package/scripts/build-binary.ts +4 -0
  26. package/src/cli/fast-help.ts +80 -0
  27. package/src/cli/setup-cli.ts +12 -3
  28. package/src/cli.ts +107 -16
  29. package/src/commands/coordinator.ts +44 -1
  30. package/src/commands/harness.ts +92 -9
  31. package/src/commands/mcp-serve.ts +3 -2
  32. package/src/commands/setup.ts +4 -0
  33. package/src/config/models-config-schema.ts +1 -0
  34. package/src/coordinator/contract.ts +1 -0
  35. package/src/coordinator-mcp/server.ts +385 -182
  36. package/src/cursor.ts +30 -2
  37. package/src/gjc-runtime/launch-worktree.ts +12 -1
  38. package/src/gjc-runtime/session-state-sidecar.ts +38 -0
  39. package/src/harness-control-plane/finalize.ts +39 -5
  40. package/src/harness-control-plane/owner.ts +9 -1
  41. package/src/harness-control-plane/phase-rollup.ts +96 -0
  42. package/src/harness-control-plane/receipt-ingest.ts +127 -0
  43. package/src/harness-control-plane/receipts.ts +229 -1
  44. package/src/harness-control-plane/rpc-adapter.ts +8 -0
  45. package/src/harness-control-plane/types.ts +29 -1
  46. package/src/internal-urls/docs-index.generated.ts +6 -5
  47. package/src/main.ts +7 -3
  48. package/src/modes/components/status-line.ts +6 -6
  49. package/src/modes/controllers/event-controller.ts +5 -4
  50. package/src/modes/interactive-mode.ts +4 -5
  51. package/src/modes/print-mode.ts +1 -1
  52. package/src/modes/theme/theme.ts +2 -2
  53. package/src/modes/utils/abort-message.ts +41 -0
  54. package/src/modes/utils/context-usage.ts +15 -8
  55. package/src/modes/utils/ui-helpers.ts +5 -6
  56. package/src/sdk.ts +9 -4
  57. package/src/session/agent-session.ts +16 -5
  58. package/src/session/session-manager.ts +20 -0
  59. package/src/setup/hermes/templates/operator-instructions.v1.md +3 -2
  60. package/src/setup/hermes-setup.ts +63 -8
  61. package/src/task/fork-context-advisory.ts +99 -0
  62. package/src/task/index.ts +31 -2
  63. package/src/task/receipt.ts +2 -0
  64. package/src/task/roi-reconciliation.ts +90 -0
  65. package/src/task/types.ts +7 -0
  66. package/src/tools/index.ts +2 -2
  67. package/src/tools/subagent-render.ts +10 -1
  68. package/src/utils/title-generator.ts +16 -2
package/src/main.ts CHANGED
@@ -33,7 +33,7 @@ import { getDefault, type SettingPath, Settings, settings } from "./config/setti
33
33
  import { initializeWithSettings } from "./discovery";
34
34
  import { exportFromFile } from "./export/html";
35
35
  import type { ExtensionUIContext } from "./extensibility/extensions/types";
36
- import { InteractiveMode, runAcpMode, runBridgeMode, runPrintMode, runRpcMode } from "./modes";
36
+ import type { InteractiveMode } from "./modes/interactive-mode";
37
37
  import { initTheme, stopThemeWatcher } from "./modes/theme/theme";
38
38
  import type { SubmittedUserInput } from "./modes/types";
39
39
  import type { MCPManager } from "./runtime-mcp";
@@ -304,6 +304,7 @@ async function runInteractiveMode(
304
304
  initialMessage?: string,
305
305
  initialImages?: ImageContent[],
306
306
  ): Promise<void> {
307
+ const { InteractiveMode } = await import("./modes/interactive-mode");
307
308
  const mode = new InteractiveMode(
308
309
  session,
309
310
  version,
@@ -706,7 +707,7 @@ async function buildSessionOptions(
706
707
  interface RunRootCommandDependencies {
707
708
  createAgentSession?: typeof createAgentSession;
708
709
  discoverAuthStorage?: typeof discoverAuthStorage;
709
- runAcpMode?: typeof runAcpMode;
710
+ runAcpMode?: (createSession: AcpSessionFactory) => Promise<void>;
710
711
  settings?: Settings;
711
712
  }
712
713
 
@@ -927,7 +928,7 @@ export async function runRootCommand(
927
928
  rawArgs,
928
929
  createSession,
929
930
  });
930
- await (deps.runAcpMode ?? runAcpMode)(createAcpSession);
931
+ await (deps.runAcpMode ?? (await import("./modes/acp")).runAcpMode)(createAcpSession);
931
932
  } else {
932
933
  const { session, setToolUIContext, modelFallbackMessage, lspServers, mcpManager, eventBus } =
933
934
  await createSession(sessionOptions);
@@ -973,8 +974,10 @@ export async function runRootCommand(
973
974
  }
974
975
 
975
976
  if (mode === "rpc" || mode === "rpc-ui") {
977
+ const { runRpcMode } = await import("./modes/rpc/rpc-mode");
976
978
  await runRpcMode(session, mode === "rpc-ui" ? setToolUIContext : undefined);
977
979
  } else if (mode === "bridge") {
980
+ const { runBridgeMode } = await import("./modes/bridge/bridge-mode");
978
981
  await runBridgeMode(session, setToolUIContext);
979
982
  } else if (isInteractive) {
980
983
  const versionCheckPromise = checkForNewVersion(VERSION).catch(() => undefined);
@@ -1014,6 +1017,7 @@ export async function runRootCommand(
1014
1017
  initialImages,
1015
1018
  );
1016
1019
  } else {
1020
+ const { runPrintMode } = await import("./modes/print-mode");
1017
1021
  await runPrintMode(session, {
1018
1022
  mode,
1019
1023
  messages: parsedArgs.messages,
@@ -1,6 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import type { AgentMessage } from "@gajae-code/agent-core";
3
- import { estimateTokens } from "@gajae-code/agent-core/compaction";
3
+ import { estimateMessageTokensHeuristic } from "@gajae-code/agent-core/compaction";
4
4
  import { type Component, truncateToWidth, visibleWidth } from "@gajae-code/tui";
5
5
  import { formatCount, getProjectDir } from "@gajae-code/utils";
6
6
  import { $ } from "bun";
@@ -50,7 +50,7 @@ export interface StatusLineSettings {
50
50
 
51
51
  /**
52
52
  * Symbol-keyed sidecar tagged onto each `AgentMessage` to memoize its
53
- * `estimateTokens` result. Keyed by message identity (the object itself);
53
+ * `estimateMessageTokensHeuristic` result. Keyed by message identity (the object itself);
54
54
  * a cheap content fingerprint detects in-place mutations (post-hoc error
55
55
  * attachment, retry-truncated branch rebuild, etc.) and forces recompute.
56
56
  *
@@ -64,11 +64,11 @@ interface TaggedMessage {
64
64
  }
65
65
 
66
66
  /**
67
- * Cheap structural fingerprint mirroring `estimateTokens`'s content walk.
67
+ * Cheap structural fingerprint mirroring `estimateMessageTokensHeuristic`'s content walk.
68
68
  * O(blocks) — only reads string `.length` and primitives, never copies or
69
69
  * serializes content. Any in-place mutation that alters total tokenized
70
70
  * content also alters one of the byte-length sums or block counts captured
71
- * here, forcing the cached `estimateTokens` value to be recomputed.
71
+ * here, forcing the cached heuristic token value to be recomputed.
72
72
  */
73
73
  function messageFingerprint(msg: AgentMessage): string {
74
74
  const role = (msg as { role?: string }).role ?? "";
@@ -136,7 +136,7 @@ function tokensForMessage(msg: AgentMessage): number {
136
136
  const tagged = msg as TaggedMessage;
137
137
  const cached = tagged[kTokenCache];
138
138
  if (cached && cached.fingerprint === fp) return cached.tokens;
139
- const tokens = estimateTokens(msg);
139
+ const tokens = estimateMessageTokensHeuristic(msg);
140
140
  tagged[kTokenCache] = { fingerprint: fp, tokens };
141
141
  return tokens;
142
142
  }
@@ -560,7 +560,7 @@ export class StatusLineComponent implements Component {
560
560
  let messagesTokens = 0;
561
561
  const lastIdx = messages.length - 1;
562
562
  for (let i = 0; i < messages.length; i++) {
563
- messagesTokens += i === lastIdx ? estimateTokens(messages[i]) : tokensForMessage(messages[i]);
563
+ messagesTokens += i === lastIdx ? estimateMessageTokensHeuristic(messages[i]) : tokensForMessage(messages[i]);
564
564
  }
565
565
 
566
566
  const usedTokens = this.#nonMessageTokensCache + messagesTokens;
@@ -20,6 +20,7 @@ import type { AgentSessionEvent } from "../../session/agent-session";
20
20
  import { isSilentAbort, readPendingDisplayTag } from "../../session/messages";
21
21
  import type { ResolveToolDetails } from "../../tools/resolve";
22
22
  import { interruptHint } from "../shared";
23
+ import { buildAbortDisplayMessage } from "../utils/abort-message";
23
24
 
24
25
  type AgentSessionEventKind = AgentSessionEvent["type"];
25
26
 
@@ -419,10 +420,10 @@ export class EventController {
419
420
  // controller ran, so reaching this branch implies the abort was NOT a
420
421
  // silent internal transition.
421
422
  const retryAttempt = this.ctx.session.retryAttempt;
422
- errorMessage =
423
- retryAttempt > 0
424
- ? `Aborted after ${retryAttempt} retry attempt${retryAttempt > 1 ? "s" : ""}`
425
- : "Operation aborted";
423
+ errorMessage = buildAbortDisplayMessage({
424
+ errorMessage: this.ctx.streamingMessage.errorMessage,
425
+ retryAttempt,
426
+ });
426
427
  this.ctx.streamingMessage.errorMessage = errorMessage;
427
428
  }
428
429
  if (silentlyAborted || ttsrSilenced) {
@@ -1057,7 +1057,7 @@ export class InteractiveMode implements InteractiveModeContext {
1057
1057
  return;
1058
1058
  }
1059
1059
  if (event.state?.enabled === true && !this.#goalModePreviousTools) {
1060
- this.#goalModePreviousTools = this.session.getActiveToolNames().filter(name => name !== "goal");
1060
+ this.#goalModePreviousTools = this.session.getActiveToolNames();
1061
1061
  }
1062
1062
  this.goalModeEnabled = event.state?.enabled === true;
1063
1063
  this.goalModePaused = event.state?.enabled !== true && event.state?.goal?.status === "paused";
@@ -1146,10 +1146,9 @@ export class InteractiveMode implements InteractiveModeContext {
1146
1146
  const restored = await this.session.goalRuntime.onThreadResumed();
1147
1147
  this.goalModeEnabled = restored?.enabled === true;
1148
1148
  this.goalModePaused = restored?.enabled !== true && restored?.goal.status === "paused";
1149
- // sdk.ts excludes "goal" from the initial active tool set unconditionally.
1150
- // Re-add it now so the agent can call resume, complete, or drop on this goal.
1149
+ // Keep `goal` armed on resumed threads; it is part of the default active tool set.
1151
1150
  if (restored?.goal) {
1152
- const previousTools = this.session.getActiveToolNames().filter(name => name !== "goal");
1151
+ const previousTools = this.session.getActiveToolNames();
1153
1152
  this.#goalModePreviousTools = previousTools;
1154
1153
  await this.session.setActiveToolsByName([...new Set([...previousTools, "goal"])]);
1155
1154
  }
@@ -1318,7 +1317,7 @@ export class InteractiveMode implements InteractiveModeContext {
1318
1317
  this.showWarning("Exit plan mode first.");
1319
1318
  return;
1320
1319
  }
1321
- const previousTools = this.session.getActiveToolNames().filter(name => name !== "goal");
1320
+ const previousTools = this.session.getActiveToolNames();
1322
1321
  const goalTools = [...new Set([...previousTools, "goal"])];
1323
1322
  this.#goalModePreviousTools = previousTools;
1324
1323
  this.goalModePaused = false;
@@ -72,7 +72,7 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
72
72
  // In text mode, output final response
73
73
  if (mode === "text") {
74
74
  const state = session.state;
75
- const lastMessage = state.messages[state.messages.length - 1];
75
+ const lastMessage = state.messages.findLast(message => message.role === "assistant");
76
76
 
77
77
  if (lastMessage?.role === "assistant") {
78
78
  const assistantMsg = lastMessage as AssistantMessage;
@@ -264,7 +264,7 @@ const UNICODE_SYMBOLS: SymbolMap = {
264
264
  "icon.context": "◫",
265
265
  "icon.cost": "💲",
266
266
  "icon.time": "⏱",
267
- "icon.pi": "π",
267
+ "icon.pi": "🦞",
268
268
  "icon.agents": "👥",
269
269
  "icon.cache": "💾",
270
270
  "icon.input": "⤵",
@@ -686,7 +686,7 @@ const ASCII_SYMBOLS: SymbolMap = {
686
686
  "icon.context": "ctx:",
687
687
  "icon.cost": "$",
688
688
  "icon.time": "t:",
689
- "icon.pi": "pi",
689
+ "icon.pi": "GJC",
690
690
  "icon.agents": "AG",
691
691
  "icon.cache": "cache",
692
692
  "icon.input": "in:",
@@ -0,0 +1,41 @@
1
+ const STREAM_IDLE_TIMEOUT_PATTERN = /\bstream stalled while waiting for the next event\b/i;
2
+ const GENERIC_ABORT_PATTERN = /^Request was aborted\.?$/i;
3
+ const ABORT_DISPLAY_LABEL_PATTERN = /^(?:Operation aborted|Aborted after \d+ retry attempts?)(?::|$)/;
4
+
5
+ export function buildAbortDisplayMessage({
6
+ errorMessage,
7
+ retryAttempt,
8
+ }: {
9
+ errorMessage?: string;
10
+ retryAttempt: number;
11
+ }): string {
12
+ const existingDisplayMessage = normalizeExistingAbortDisplayMessage(errorMessage);
13
+ if (existingDisplayMessage) return existingDisplayMessage;
14
+
15
+ const baseMessage =
16
+ retryAttempt > 0
17
+ ? `Aborted after ${retryAttempt} retry attempt${retryAttempt > 1 ? "s" : ""}`
18
+ : "Operation aborted";
19
+ const cause = normalizeAbortCause(errorMessage);
20
+ if (!cause) return baseMessage;
21
+
22
+ return `${baseMessage}: ${cause}${streamIdleTimeoutHint(cause)}`;
23
+ }
24
+
25
+ function normalizeExistingAbortDisplayMessage(errorMessage: string | undefined): string {
26
+ const trimmed = errorMessage?.trim();
27
+ if (!trimmed || !ABORT_DISPLAY_LABEL_PATTERN.test(trimmed)) return "";
28
+ return trimmed;
29
+ }
30
+
31
+ function normalizeAbortCause(errorMessage: string | undefined): string {
32
+ const trimmed = errorMessage?.trim();
33
+ if (!trimmed || GENERIC_ABORT_PATTERN.test(trimmed)) return "";
34
+ return trimmed;
35
+ }
36
+
37
+ function streamIdleTimeoutHint(cause: string): string {
38
+ if (!STREAM_IDLE_TIMEOUT_PATTERN.test(cause)) return "";
39
+ const separator = /[.!?]$/.test(cause) ? " " : ". ";
40
+ return `${separator}Hint: set PI_STREAM_IDLE_TIMEOUT_MS=300000 for slow reasoning/proxy streams, or PI_STREAM_IDLE_TIMEOUT_MS=0 to disable the watchdog.`;
41
+ }
@@ -1,8 +1,12 @@
1
1
  import type { AgentMessage } from "@gajae-code/agent-core";
2
2
  import type { CompactionSettings } from "@gajae-code/agent-core/compaction";
3
- import { effectiveReserveTokens, estimateTokens, resolveThresholdTokens } from "@gajae-code/agent-core/compaction";
3
+ import {
4
+ effectiveReserveTokens,
5
+ estimateMessageTokensHeuristic,
6
+ estimateTextTokensHeuristic,
7
+ resolveThresholdTokens,
8
+ } from "@gajae-code/agent-core/compaction";
4
9
  import type { Model } from "@gajae-code/ai";
5
- import { countTokens } from "@gajae-code/natives";
6
10
  import { formatNumber } from "@gajae-code/utils";
7
11
  import type { Skill } from "../../extensibility/skills";
8
12
  import type { AgentSession } from "../../session/agent-session";
@@ -46,7 +50,7 @@ export function estimateSkillsTokens(skills: readonly Skill[]): number {
46
50
  // concatenated form, so encode each piece separately and sum.
47
51
  fragments.push(skill.name, skill.description);
48
52
  }
49
- return countTokens(fragments);
53
+ return estimateTextTokensHeuristic(fragments);
50
54
  }
51
55
 
52
56
  export function estimateToolSchemaTokens(
@@ -61,7 +65,7 @@ export function estimateToolSchemaTokens(
61
65
  // Schema may contain functions or cycles; ignore.
62
66
  }
63
67
  }
64
- return countTokens(fragments);
68
+ return estimateTextTokensHeuristic(fragments);
65
69
  }
66
70
 
67
71
  /**
@@ -100,8 +104,11 @@ function computeNonMessageBreakdown(session: AgentSession): {
100
104
  const toolsTokens = estimateToolSchemaTokens(session.agent?.state?.tools ?? []);
101
105
  const systemPromptParts = session.systemPrompt ?? [];
102
106
  const rulesTokens = estimateRulesTokens(systemPromptParts);
103
- const systemContextTokens = countTokens(systemPromptParts.slice(1));
104
- const systemPromptTokens = Math.max(0, countTokens(systemPromptParts[0] ?? "") - skillsTokens - rulesTokens);
107
+ const systemContextTokens = estimateTextTokensHeuristic(systemPromptParts.slice(1));
108
+ const systemPromptTokens = Math.max(
109
+ 0,
110
+ estimateTextTokensHeuristic(systemPromptParts[0] ?? "") - skillsTokens - rulesTokens,
111
+ );
105
112
  return { rulesTokens, skillsTokens, toolsTokens, systemContextTokens, systemPromptTokens };
106
113
  }
107
114
 
@@ -112,7 +119,7 @@ function estimateRulesTokens(systemPromptParts: readonly string[]): number {
112
119
  fragments.push(match[0]);
113
120
  }
114
121
  }
115
- return fragments.length === 0 ? 0 : countTokens(fragments);
122
+ return fragments.length === 0 ? 0 : estimateTextTokensHeuristic(fragments);
116
123
  }
117
124
 
118
125
  function splitLastUserTurn(messages: readonly AgentMessage[]): {
@@ -130,7 +137,7 @@ function splitLastUserTurn(messages: readonly AgentMessage[]): {
130
137
  let regularMessagesTokens = 0;
131
138
  let lastUserTurnTokens = 0;
132
139
  for (let i = 0; i < messages.length; i++) {
133
- const tokens = estimateTokens(messages[i]);
140
+ const tokens = estimateMessageTokensHeuristic(messages[i]);
134
141
  if (i === lastUserIndex) {
135
142
  lastUserTurnTokens = tokens;
136
143
  } else {
@@ -27,6 +27,7 @@ import {
27
27
  } from "../../session/messages";
28
28
  import type { SessionContext } from "../../session/session-manager";
29
29
  import { formatBytes, formatDuration } from "../../tools/render-utils";
30
+ import { buildAbortDisplayMessage } from "./abort-message";
30
31
 
31
32
  type TextBlock = { type: "text"; text: string };
32
33
  interface RenderInitialMessagesOptions {
@@ -319,12 +320,10 @@ export class UiHelpers {
319
320
  !isAbortedSilently && (message.stopReason === "aborted" || message.stopReason === "error");
320
321
  const errorMessage = hasErrorStop
321
322
  ? message.stopReason === "aborted"
322
- ? (() => {
323
- const retryAttempt = this.ctx.session.retryAttempt;
324
- return retryAttempt > 0
325
- ? `Aborted after ${retryAttempt} retry attempt${retryAttempt > 1 ? "s" : ""}`
326
- : "Operation aborted";
327
- })()
323
+ ? buildAbortDisplayMessage({
324
+ errorMessage: message.errorMessage,
325
+ retryAttempt: this.ctx.session.retryAttempt,
326
+ })
328
327
  : message.errorMessage || "Error"
329
328
  : null;
330
329
 
package/src/sdk.ts CHANGED
@@ -1622,9 +1622,14 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1622
1622
  };
1623
1623
 
1624
1624
  const toolNamesFromRegistry = Array.from(toolRegistry.keys());
1625
- const requestedToolNames =
1626
- (options.toolNames ? [...new Set(options.toolNames.map(name => name.toLowerCase()))] : undefined) ??
1627
- toolNamesFromRegistry;
1625
+ const requestedToolNames = options.toolNames
1626
+ ? [
1627
+ ...new Set([
1628
+ ...options.toolNames.map(name => name.toLowerCase()),
1629
+ ...(settings.get("goal.enabled") ? ["goal"] : []),
1630
+ ]),
1631
+ ]
1632
+ : toolNamesFromRegistry;
1628
1633
  const normalizedRequested = requestedToolNames.filter(name => toolRegistry.has(name));
1629
1634
  const requestedToolNameSet = new Set(normalizedRequested);
1630
1635
  // Effective discovery mode only covers built-in tools; MCP tool discovery
@@ -1635,7 +1640,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1635
1640
  const defaultInactiveToolNames = new Set(
1636
1641
  registeredTools.filter(tool => tool.definition.defaultInactive).map(tool => tool.definition.name),
1637
1642
  );
1638
- const requestedActiveToolNames = normalizedRequested.filter(name => name !== "goal");
1643
+ const requestedActiveToolNames = normalizedRequested;
1639
1644
  const initialRequestedActiveToolNames = options.toolNames
1640
1645
  ? requestedActiveToolNames
1641
1646
  : requestedActiveToolNames.filter(name => !defaultInactiveToolNames.has(name));
@@ -41,6 +41,7 @@ import {
41
41
  calculatePromptTokens,
42
42
  collectEntriesForBranchSummary,
43
43
  compact,
44
+ estimateMessageTokensHeuristic,
44
45
  estimateTokens,
45
46
  generateBranchSummary,
46
47
  generateHandoff,
@@ -4388,7 +4389,7 @@ export class AgentSession {
4388
4389
  return false;
4389
4390
  }
4390
4391
 
4391
- const previousTools = this.getActiveToolNames().filter(name => name !== "goal");
4392
+ const previousTools = this.getActiveToolNames();
4392
4393
  const goalTools = [...new Set([...previousTools, "goal"])];
4393
4394
  await this.#goalRuntime.createGoal({ objective: pendingGoal.objective });
4394
4395
  await this.setActiveToolsByName(goalTools);
@@ -6063,6 +6064,9 @@ export class AgentSession {
6063
6064
  return undefined;
6064
6065
  }
6065
6066
 
6067
+ // getBranch() returns materialized copies for blob-externalized entries, so
6068
+ // the pruning mutations must be written back into the canonical store.
6069
+ this.sessionManager.applyEntryMessageUpdates(result.prunedEntries);
6066
6070
  await this.sessionManager.rewriteEntries();
6067
6071
  const sessionContext = this.buildDisplaySessionContext();
6068
6072
  this.agent.replaceMessages(sessionContext.messages);
@@ -6507,12 +6511,18 @@ export class AgentSession {
6507
6511
  // Case 2: Threshold - turn succeeded but context is getting large
6508
6512
  // Skip if this was an error (non-overflow errors don't have usage data)
6509
6513
  if (assistantMessage.stopReason === "error") return;
6510
- const pruneResult = await this.#pruneToolOutputs();
6511
6514
  let contextTokens = calculateContextTokens(assistantMessage.usage);
6515
+ const maxOutputTokens = this.model?.maxTokens ?? 0;
6516
+ // Cache-epoch invariant: pruning rewrites already-sent toolResult history,
6517
+ // which breaks the provider prompt-cache prefix mid-epoch. Only prune at a
6518
+ // sanctioned maintenance boundary, i.e. when the un-pruned context already
6519
+ // crosses the compaction threshold. Pruning may then avert full compaction.
6520
+ if (!shouldCompact(contextTokens, contextWindow, compactionSettings, maxOutputTokens)) return;
6521
+ const pruneResult = await this.#pruneToolOutputs();
6512
6522
  if (pruneResult) {
6513
6523
  contextTokens = Math.max(0, contextTokens - pruneResult.tokensSaved);
6514
6524
  }
6515
- if (shouldCompact(contextTokens, contextWindow, compactionSettings, this.model?.maxTokens ?? 0)) {
6525
+ if (shouldCompact(contextTokens, contextWindow, compactionSettings, maxOutputTokens)) {
6516
6526
  // Try promotion first — if a larger model is available, switch instead of compacting
6517
6527
  const promoted = await this.#tryContextPromotion(assistantMessage);
6518
6528
  if (!promoted) {
@@ -8338,6 +8348,7 @@ export class AgentSession {
8338
8348
  onChunk,
8339
8349
  signal: abortController.signal,
8340
8350
  sessionKey: this.sessionId,
8351
+ cwd,
8341
8352
  timeout: clampTimeout("bash") * 1000,
8342
8353
  env: buildGjcRuntimeSessionEnv({
8343
8354
  sessionFile: this.sessionManager.getSessionFile(),
@@ -9527,7 +9538,7 @@ export class AgentSession {
9527
9538
  // No usage data - estimate all messages
9528
9539
  let estimated = 0;
9529
9540
  for (const message of messages) {
9530
- estimated += estimateTokens(message);
9541
+ estimated += estimateMessageTokensHeuristic(message);
9531
9542
  }
9532
9543
  return {
9533
9544
  tokens: estimated,
@@ -9537,7 +9548,7 @@ export class AgentSession {
9537
9548
  const usageTokens = calculatePromptTokens(lastUsage);
9538
9549
  let trailingTokens = 0;
9539
9550
  for (let i = lastUsageIndex + 1; i < messages.length; i++) {
9540
- trailingTokens += estimateTokens(messages[i]);
9551
+ trailingTokens += estimateMessageTokensHeuristic(messages[i]);
9541
9552
  }
9542
9553
 
9543
9554
  return {
@@ -3125,6 +3125,26 @@ export class SessionManager {
3125
3125
  return entry.id;
3126
3126
  }
3127
3127
 
3128
+ /**
3129
+ * Write mutated message entries back into the canonical entry store by id.
3130
+ *
3131
+ * `getBranch()` materializes resident-blob entries into copies, so in-place
3132
+ * mutation of returned entries (e.g. pruning tool outputs) does not affect
3133
+ * the canonical store. This applies such mutations for real.
3134
+ */
3135
+ applyEntryMessageUpdates(entries: readonly SessionMessageEntry[]): void {
3136
+ for (const updated of entries) {
3137
+ const canonical = this.#byId.get(updated.id);
3138
+ if (canonical?.type !== "message") continue;
3139
+ const residentEntry = prepareEntryForResidentSync(
3140
+ { ...canonical, message: updated.message },
3141
+ this.#residentBlobStore,
3142
+ ) as SessionMessageEntry;
3143
+ canonical.message = residentEntry.message;
3144
+ }
3145
+ this.#needsFullRewriteOnNextPersist = true;
3146
+ }
3147
+
3128
3148
  /**
3129
3149
  * Rewrite the session file after in-place entry updates.
3130
3150
  * Use sparingly (e.g., pruning old tool outputs).
@@ -10,15 +10,16 @@ These instructions teach a Hermes-style coordinator how to operate GJC through t
10
10
  2. Send exactly one bounded task prompt with `{{TOOL_PREFIX}}_send_prompt`.
11
11
  3. Store the returned `turn_id`.
12
12
  4. Poll `{{TOOL_PREFIX}}_read_turn` or `{{TOOL_PREFIX}}_await_turn` for that `turn_id` until the turn is terminal.
13
+ If a second task is needed while one turn is active, pass `queue: true`; the next queued turn is promoted after the active turn is reported terminal.
13
14
  5. If GJC asks a structured question, use `{{TOOL_PREFIX}}_list_questions` and answer with `{{TOOL_PREFIX}}_submit_question_answer`.
14
15
  6. Use `{{TOOL_PREFIX}}_report_status` for coordinator-visible status and final reports.
15
16
  7. Use `{{TOOL_PREFIX}}_read_tail` only as advisory debug output when structured turn state is insufficient.
16
17
 
17
18
  Do not report completion to the user until the GJC turn is terminal. Do not infer completion from terminal scrollback alone.
18
19
 
19
- ## Model and provider policy
20
+ ## Worktree, model, and provider policy
20
21
 
21
- The Hermes bridge does not choose a model/provider. When no session command is configured, GJC uses its normal local model/provider resolution. If the operator config supplies `GJC_COORDINATOR_MCP_SESSION_COMMAND`, preserve it as explicit user intent.
22
+ The Hermes bridge does not choose a model/provider. Generated setup configures `GJC_COORDINATOR_MCP_SESSION_COMMAND` to `gjc --worktree` by default, so GJC creates and tracks the worktree while still using normal local model/provider resolution. Keep worktree creation inside GJC rather than creating unmanaged Hermes-side git worktrees; this preserves the original project identity for session listing and resume. If the operator config supplies a different `GJC_COORDINATOR_MCP_SESSION_COMMAND`, preserve it as explicit user intent.
22
23
 
23
24
  Provider-specific commands are examples only, never product defaults.
24
25
 
@@ -24,6 +24,8 @@ export interface HermesSetupFlags {
24
24
  repo?: string;
25
25
  profile?: string;
26
26
  sessionCommand?: string;
27
+ noWorktree?: boolean;
28
+ worktreeName?: string;
27
29
  stateRoot?: string;
28
30
  mutation?: string[];
29
31
  artifactByteCap?: string;
@@ -47,6 +49,11 @@ export interface CoordinatorSetupSpec {
47
49
  repo?: string;
48
50
  };
49
51
  sessionCommand?: string;
52
+ sessionCommandSource: "default" | "explicit";
53
+ worktree: {
54
+ enabled: boolean;
55
+ name?: string;
56
+ };
50
57
  stateRoot?: string;
51
58
  mutationPolicy: {
52
59
  classes: HermesMutationClass[];
@@ -157,6 +164,39 @@ function parseByteCap(value: string | undefined): number | undefined {
157
164
  return parsed;
158
165
  }
159
166
 
167
+ function normalizeWorktreeName(value: string | undefined): string | undefined {
168
+ const trimmed = optionalTrim(value);
169
+ if (!trimmed) return undefined;
170
+ if (trimmed.startsWith("-") || !/^[a-zA-Z0-9][a-zA-Z0-9._/-]{0,127}$/.test(trimmed)) {
171
+ throw new HermesSetupError(`Invalid Hermes worktree name: ${trimmed}`, 2);
172
+ }
173
+ return trimmed;
174
+ }
175
+
176
+ function resolveHermesWorktree(flags: HermesSetupFlags): CoordinatorSetupSpec["worktree"] {
177
+ if (flags.noWorktree && flags.worktreeName) {
178
+ throw new HermesSetupError("Use either --no-worktree or --worktree-name, not both.", 2);
179
+ }
180
+ const name = normalizeWorktreeName(flags.worktreeName);
181
+ return flags.noWorktree ? { enabled: false } : { enabled: true, ...(name ? { name } : {}) };
182
+ }
183
+
184
+ function resolveHermesSessionCommand(gjcCommand: string, flags: HermesSetupFlags): string {
185
+ const explicit = optionalTrim(flags.sessionCommand);
186
+ if (explicit) {
187
+ if (flags.noWorktree || flags.worktreeName) {
188
+ throw new HermesSetupError(
189
+ "Use either --session-command or Hermes worktree flags; explicit session commands are preserved exactly.",
190
+ 2,
191
+ );
192
+ }
193
+ return explicit;
194
+ }
195
+ const worktree = resolveHermesWorktree(flags);
196
+ if (!worktree.enabled) return gjcCommand;
197
+ return worktree.name ? `${gjcCommand} --worktree ${worktree.name}` : `${gjcCommand} --worktree`;
198
+ }
199
+
160
200
  function normalizeInstallTarget(flags: HermesSetupFlags): CoordinatorSetupSpec["installTarget"] {
161
201
  if (flags.target && flags.profileDir) {
162
202
  throw new HermesSetupError("Use exactly one of --target or --profile-dir for Hermes setup install targets.", 2);
@@ -169,20 +209,24 @@ function normalizeInstallTarget(flags: HermesSetupFlags): CoordinatorSetupSpec["
169
209
 
170
210
  export function buildHermesSetupSpec(flags: HermesSetupFlags): CoordinatorSetupSpec {
171
211
  const roots = normalizeRoots(flags.root);
212
+ const gjcCommand = optionalTrim(flags.gjcCommand) ?? DEFAULT_GJC_COMMAND;
213
+ const sessionCommand = resolveHermesSessionCommand(gjcCommand, flags);
172
214
  return {
173
215
  schemaVersion: 1,
174
216
  coordinator: "hermes",
175
217
  serverKey: optionalTrim(flags.serverKey) ?? DEFAULT_SERVER_KEY,
176
218
  serverName: COORDINATOR_MCP_SERVER_NAME,
177
219
  protocolVersion: COORDINATOR_MCP_PROTOCOL_VERSION,
178
- gjcCommand: optionalTrim(flags.gjcCommand) ?? DEFAULT_GJC_COMMAND,
220
+ gjcCommand,
179
221
  args: ["mcp-serve", "coordinator"],
180
222
  roots,
181
223
  namespace: {
182
224
  ...(optionalTrim(flags.profile) ? { profile: optionalTrim(flags.profile) } : {}),
183
225
  ...(optionalTrim(flags.repo) ? { repo: optionalTrim(flags.repo) } : {}),
184
226
  },
185
- ...(optionalTrim(flags.sessionCommand) ? { sessionCommand: flags.sessionCommand } : {}),
227
+ worktree: resolveHermesWorktree(flags),
228
+ sessionCommandSource: optionalTrim(flags.sessionCommand) ? "explicit" : "default",
229
+ sessionCommand,
186
230
  ...(optionalTrim(flags.stateRoot) ? { stateRoot: path.resolve(flags.stateRoot!) } : {}),
187
231
  mutationPolicy: {
188
232
  classes: parseMutationClasses(flags.mutation),
@@ -214,6 +258,8 @@ function signaturePayload(spec: CoordinatorSetupSpec): Record<string, unknown> {
214
258
  contractDocVersion: spec.contractDocVersion,
215
259
  coordinator: spec.coordinator,
216
260
  mutationClasses: spec.mutationPolicy.classes,
261
+ worktree: spec.worktree,
262
+ sessionCommandSource: spec.sessionCommandSource,
217
263
  namespace: spec.namespace,
218
264
  operatorTemplateVersion: spec.operatorTemplateVersion,
219
265
  roots: spec.roots,
@@ -360,7 +406,9 @@ async function runSmoke(spec: CoordinatorSetupSpec): Promise<HermesSetupResult["
360
406
  const requiredTools = [...COORDINATOR_MCP_TOOL_NAMES];
361
407
  const server = createCoordinatorMcpServer({ env: {} });
362
408
  const listed = await server.handleJsonRpc({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} });
363
- const advertised = new Set((listed.result?.tools ?? []).map((tool: { name: string }) => tool.name));
409
+ const listedResult = isRecord(listed.result) ? listed.result : {};
410
+ const tools = Array.isArray(listedResult.tools) ? listedResult.tools : [];
411
+ const advertised = new Set(tools.map(tool => (isRecord(tool) ? String(tool.name) : "")));
364
412
  const missingTools = requiredTools.filter(tool => !advertised.has(tool));
365
413
  return {
366
414
  ok: missingTools.length === 0,
@@ -398,11 +446,18 @@ export async function runHermesSetup(flags: HermesSetupFlags): Promise<HermesSet
398
446
  mode,
399
447
  files_written,
400
448
  previews,
401
- warnings: spec.sessionCommand
402
- ? [
403
- "Using explicit GJC_COORDINATOR_MCP_SESSION_COMMAND exactly as supplied; provider/model validation is not performed.",
404
- ]
405
- : ["No session command supplied; spawned sessions use the default GJC command/model resolution."],
449
+ warnings:
450
+ spec.sessionCommandSource === "explicit"
451
+ ? [
452
+ "Using explicit GJC_COORDINATOR_MCP_SESSION_COMMAND exactly as supplied; provider/model/worktree validation is not performed.",
453
+ ]
454
+ : spec.worktree.enabled
455
+ ? [
456
+ `GJC_COORDINATOR_MCP_SESSION_COMMAND defaults to '${spec.sessionCommand}' so GJC owns worktree creation and resume identity.`,
457
+ ]
458
+ : [
459
+ "GJC_COORDINATOR_MCP_SESSION_COMMAND defaults to the configured gjc command with worktree isolation disabled by user request.",
460
+ ],
406
461
  smoke,
407
462
  };
408
463
  }