march-cli 0.1.36 → 0.1.37

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "march-cli",
3
- "version": "0.1.36",
3
+ "version": "0.1.37",
4
4
  "description": "March CLI — terminal-native coding agent with context reconstruction",
5
5
  "type": "module",
6
6
  "main": "./src/main.mjs",
@@ -13,7 +13,7 @@ export function createCodeSearchTool({ engine, stateRoot = null }) {
13
13
  return defineTool({
14
14
  name: "code_search",
15
15
  label: "Code Search",
16
- description: "Native code-aware search over the workspace. Use it to locate relevant code snippets before reading full files; use grep for exact string confirmation.",
16
+ description: "Native semantic/symbol code search over the workspace. Use it first for unknown entry points, cross-module flows, responsibility boundaries, and related implementations. Use grep/read afterward for exact confirmation before editing or making claims.",
17
17
  parameters: Type.Object({
18
18
  query: Type.Optional(Type.String({ description: "Natural-language or symbol query" })),
19
19
  path: Type.Optional(Type.String({ description: "Relative or absolute workspace path to search; default current workspace" })),
@@ -28,19 +28,16 @@ import { registerCustomProviders } from "../provider/custom-provider.mjs";
28
28
  import { injectHostedTools } from "../provider/hosted-tools.mjs";
29
29
  import { createRunnerLifecycle } from "./lifecycle/runner-lifecycle.mjs";
30
30
  import { createRunnerProviderQuotaRuntime } from "./runner/provider-quota-runtime.mjs";
31
+ import { appendRunnerTurnHistory, createRunnerHistoryStore } from "../history/runner.mjs";
31
32
  export { MARCH_BASE_TOOL_NAMES, installModelPayloadDumper };
32
33
  export { createDefaultSessionManager, resolveRunnerSessionManager } from "./runner/runner-init.mjs";
33
34
  export { getRunnerSessionStats, syncEngineSessionState } from "./runner/runner-session-state.mjs";
34
- export async function createRunner({ cwd, modelId = null, provider = null, providers = {}, stateRoot, ui, memoryRoot = null, profilePaths = null, memoryStore = null, memoryTools = [], remoteMemorySources = [], shellRuntime = null, mcpTools = [], mcpInjections = [], mcpClientManager = null, webTools = [], namespace = "", sessionManager = null, useRuntimeHost = false, projectMarchDir = null, syncPiSidecar = false, extensionPaths = [], lifecycleHooks = [], lifecycleDiagnostics = [], authStorage = null, permissionController = null, modelContextDumper = null, turnNotifier = null, logger = null, onModelPayload = null, createAgentSessionImpl = createAgentSession, createAgentSessionRuntimeImpl, createRuntimeServices, createRuntimeSessionFromServices, maxTurns, trimBatch, serviceTier = null, hostedTools = {} }) {
35
+ export async function createRunner({ cwd, modelId = null, provider = null, providers = {}, stateRoot, ui, memoryRoot = null, profilePaths = null, memoryStore = null, memoryTools = [], remoteMemorySources = [], shellRuntime = null, mcpTools = [], mcpInjections = [], mcpClientManager = null, webTools = [], namespace = "", sessionManager = null, useRuntimeHost = false, projectMarchDir = null, syncPiSidecar = false, extensionPaths = [], lifecycleHooks = [], lifecycleDiagnostics = [], authStorage = null, permissionController = null, modelContextDumper = null, turnNotifier = null, logger = null, onModelPayload = null, onLspStatusChange = null, createAgentSessionImpl = createAgentSession, createAgentSessionRuntimeImpl, createRuntimeServices, createRuntimeSessionFromServices, maxTurns, trimBatch, serviceTier = null, hostedTools = {} }) {
35
36
  installCodexLargeContextGuard();
36
37
  installCodexTransportCompression();
37
38
  installCodexWebSocketEventDebug();
38
- if (!useRuntimeHost && extensionPaths.length > 0) {
39
- throw new Error("--extension requires the default pi runtime host path");
40
- }
41
- const authConfig = authStorage
42
- ? { authStorage, hasAuth: true }
43
- : createMarchAuthStorage({ provider: provider ?? "deepseek", providers, cwd });
39
+ if (!useRuntimeHost && extensionPaths.length > 0) throw new Error("--extension requires the default pi runtime host path");
40
+ const authConfig = authStorage ? { authStorage, hasAuth: true } : createMarchAuthStorage({ provider: provider ?? "deepseek", providers, cwd });
44
41
  if (!authConfig.hasAuth) throw new Error("No providers configured. Run: march provider --config");
45
42
  const resolvedAuth = authConfig.authStorage;
46
43
  const modelRegistry = ModelRegistry.create(resolvedAuth);
@@ -55,8 +52,9 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
55
52
  retry: { enabled: true, maxRetries: 3, baseDelayMs: 2000 },
56
53
  });
57
54
  const { ui: runtimeUi, eventBus: runtimeUiEvents, detach: detachRuntimeUi } = createRuntimeUiBridge(ui);
58
- const lspService = new LspService({ cwd, onEvent: (event) => runtimeUi.status?.(formatLspServiceEvent(event)) });
55
+ const lspService = new LspService({ cwd, onEvent: (event) => runtimeUi.status?.(formatLspServiceEvent(event)), onStatusChange: (event) => onLspStatusChange?.(event) });
59
56
  const engine = new ContextEngine({ cwd, modelId, provider, namespace, memoryRoot, profilePaths, remoteMemorySources, shellRuntime, lspService, injections: mcpInjections, maxTurns, trimBatch });
57
+ const historyStore = createRunnerHistoryStore({ stateRoot, cwd });
60
58
  const resolvedSessionManager = resolveRunnerSessionManager(cwd, sessionManager);
61
59
  const sessionBinding = createSessionBinding(null);
62
60
  let currentModelCallKind = "model", currentTurnId = null, currentPromptForContext = "";
@@ -72,7 +70,7 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
72
70
  providers,
73
71
  sessionManager: resolvedSessionManager, sessionBinding, engine, ui: runtimeUi,
74
72
  projectMarchDir,
75
- memoryTools, memoryStore, shellRuntime, lspService, mcpTools, webTools,
73
+ memoryTools, memoryStore, historyStore, shellRuntime, lspService, mcpTools, webTools,
76
74
  lifecycle, permissionController, extensionPaths, hostedTools,
77
75
  onRebind: (session) => {
78
76
  installModelPayloadDumper(session, modelContextDumper, () => currentModelCallKind, onLoggedModelPayload, injectMarchSystemContext);
@@ -85,7 +83,7 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
85
83
  } else {
86
84
  const sessionOptions = resolveRunnerSessionOptions({
87
85
  cwd, stateRoot, provider, modelId, modelRegistry, engine, ui: runtimeUi,
88
- memoryTools, shellRuntime, lspService, mcpTools, webTools, lifecycle, permissionController,
86
+ memoryTools, historyStore, shellRuntime, lspService, mcpTools, webTools, lifecycle, permissionController,
89
87
  authStorage: resolvedAuth, projectMarchDir,
90
88
  getCurrentModel: () => sessionBinding.get()?.model ?? selectedModel,
91
89
  });
@@ -134,6 +132,7 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
134
132
  syncCurrentPiSidecar,
135
133
  autoNameSession,
136
134
  contextMode,
135
+ recordHistory: (turn) => appendRunnerTurnHistory({ store: historyStore, turn, sessionStats: getRunnerSessionStats(sessionBinding.get(), runtimeHost), modelId: engine.modelId, provider: engine.provider }),
137
136
  });
138
137
  notifyTurnEndDetached(turnNotifier, {
139
138
  status: "success",
@@ -10,6 +10,7 @@ export async function createRunnerProcessClient({
10
10
  runnerOptions,
11
11
  ui,
12
12
  onModelPayload = null,
13
+ onLspStatusChange = null,
13
14
  entry = fileURLToPath(DEFAULT_ENTRY),
14
15
  forkImpl = fork,
15
16
  timeoutMs = 0,
@@ -46,6 +47,10 @@ export async function createRunnerProcessClient({
46
47
  target: {
47
48
  ...createRuntimeUiEventTarget(ui),
48
49
  modelPayload: (event) => onModelPayload?.(event),
50
+ lspStatusChange: async (event) => {
51
+ await active?.runner?.refreshState?.();
52
+ onLspStatusChange?.(event);
53
+ },
49
54
  },
50
55
  timeoutMs,
51
56
  });
@@ -93,6 +93,7 @@ export async function createIsolatedRunner(options = {}, deps = {}) {
93
93
  }),
94
94
  logger,
95
95
  onModelPayload: (event) => d.peer.notify("modelPayload", pickModelPayloadEvent(event)),
96
+ onLspStatusChange: (event) => d.peer.notify("lspStatusChange", pickLspStatusEvent(event)),
96
97
  });
97
98
 
98
99
  const originalDispose = runner.dispose;
@@ -109,3 +110,7 @@ export async function createIsolatedRunner(options = {}, deps = {}) {
109
110
  function pickModelPayloadEvent({ estimatedTokens, provider, model, kind, turnId } = {}) {
110
111
  return { estimatedTokens, provider, model, kind, turnId };
111
112
  }
113
+
114
+ function pickLspStatusEvent({ id, root, status, reason, managed } = {}) {
115
+ return { id, root, status, reason, managed };
116
+ }
@@ -20,6 +20,7 @@ export async function createRunnerRuntimeHost({
20
20
  ui,
21
21
  projectMarchDir = null,
22
22
  memoryTools = [],
23
+ historyStore = null,
23
24
  shellRuntime = null,
24
25
  lspService = null,
25
26
  mcpTools = [],
@@ -56,6 +57,7 @@ export async function createRunnerRuntimeHost({
56
57
  engine,
57
58
  ui,
58
59
  memoryTools,
60
+ historyStore,
59
61
  shellRuntime,
60
62
  lspService,
61
63
  mcpTools,
@@ -10,6 +10,7 @@ export function resolveRunnerSessionOptions({
10
10
  engine,
11
11
  ui,
12
12
  memoryTools = [],
13
+ historyStore = null,
13
14
  shellRuntime = null,
14
15
  lspService = null,
15
16
  mcpTools = [],
@@ -31,7 +32,7 @@ export function resolveRunnerSessionOptions({
31
32
  ?? (provider && modelId ? getModel(provider, modelId) : null);
32
33
  if (!model) throw new Error(`Model not found: ${provider}/${modelId}`);
33
34
 
34
- const customTools = createMarchCustomTools({ cwd, engine, ui, memoryTools, shellRuntime, lspService, mcpTools, webTools, lifecycle, permissionController, authStorage, projectMarchDir, stateRoot, getCurrentModel: () => getCurrentModel?.() ?? model });
35
+ const customTools = createMarchCustomTools({ cwd, engine, ui, memoryTools, historyStore, shellRuntime, lspService, mcpTools, webTools, lifecycle, permissionController, authStorage, projectMarchDir, stateRoot, getCurrentModel: () => getCurrentModel?.() ?? model });
35
36
  const customToolNames = customTools.map((tool) => tool.name);
36
37
  const tools = [
37
38
  ...customToolNames.filter((name) => name === "read"),
@@ -13,11 +13,13 @@ import { initImageGen } from "../image-gen/index.mjs";
13
13
  import { createSuperGrokTool } from "../supergrok/tool.mjs";
14
14
  import { createBrowserTools } from "../browser/tools/index.mjs";
15
15
  import { createRuntimeRestartTool } from "./lifecycle/runtime-restart-tool.mjs";
16
+ import { createHistorySearchTool } from "../history/tool.mjs";
16
17
 
17
- export function createMarchCustomTools({ cwd, engine, ui, memoryTools = [], shellRuntime = null, lspService = null, mcpTools = [], webTools = [], lifecycle = null, permissionController = null, authStorage = null, projectMarchDir = null, stateRoot = null, getCurrentModel = null }) {
18
+ export function createMarchCustomTools({ cwd, engine, ui, memoryTools = [], historyStore = null, shellRuntime = null, lspService = null, mcpTools = [], webTools = [], lifecycle = null, permissionController = null, authStorage = null, projectMarchDir = null, stateRoot = null, getCurrentModel = null }) {
18
19
  const commandExecTool = createCommandExecTool({ cwd });
19
20
  const codeSearchTool = createCodeSearchTool({ engine, stateRoot });
20
21
  const contextStatsTool = createContextStatsTool({ engine });
22
+ const historySearchTool = createHistorySearchTool({ store: historyStore });
21
23
  const editFileTool = createEditFileTool({ engine, ui, lspService });
22
24
  const readFileTool = createReadFileTool({ engine });
23
25
  const readImageTool = createReadImageTool({ engine, getCurrentModel });
@@ -36,6 +38,7 @@ export function createMarchCustomTools({ cwd, engine, ui, memoryTools = [], shel
36
38
  commandExecTool,
37
39
  editFileTool,
38
40
  ...createShellTools(shellRuntime),
41
+ ...(historySearchTool ? [historySearchTool] : []),
39
42
  ...memoryTools,
40
43
  ...mcpTools,
41
44
  ...webTools,
@@ -1,5 +1,7 @@
1
1
  import { formatToolStartLine, formatToolSuccessSummary } from "../tool-summary.mjs";
2
2
 
3
+ const TOOL_ERROR_EXCERPT_LIMIT = 4000;
4
+
3
5
  export function createTurnEventState() {
4
6
  return {
5
7
  draft: "",
@@ -9,6 +11,7 @@ export function createTurnEventState() {
9
11
  assistantReplyOpen: false,
10
12
  assistantContextParts: [],
11
13
  activeToolContextPart: null,
14
+ toolCalls: [],
12
15
  };
13
16
  }
14
17
 
@@ -19,10 +22,12 @@ export function handleRunnerSessionEvent(event, { ui, engine, state }) {
19
22
  if (event.type === "tool_execution_start") {
20
23
  closeAssistantReply({ ui, state });
21
24
  appendToolStartContext(state, event.toolName, event.args);
25
+ recordToolStart(state, event.toolName, event.args);
22
26
  ui.toolStart(event.toolName, event.args);
23
27
  }
24
28
  if (event.type === "tool_execution_end") {
25
29
  updateToolEndContext(state, event.toolName, event.isError, event.result);
30
+ recordToolEnd(state, event.toolName, event.isError, event.result);
26
31
  ui.toolEnd(event.toolName, event.isError, event.result);
27
32
  }
28
33
  if (event.type === "auto_retry_start") {
@@ -109,3 +114,39 @@ function updateToolEndContext(state, name, isError, result) {
109
114
  if (summary && summary !== "done") part.text = `${part.text.trimEnd()} (${summary})\n`;
110
115
  state.activeToolContextPart = null;
111
116
  }
117
+
118
+ function recordToolStart(state, name, args) {
119
+ state.toolCalls.push({ name, args: cloneJson(args), status: "running" });
120
+ }
121
+
122
+ function recordToolEnd(state, name, isError, result) {
123
+ const call = [...state.toolCalls].reverse().find((item) => item.name === name && item.status === "running");
124
+ if (!call) return;
125
+ call.status = isError ? "failed" : "success";
126
+ if (!isError) return;
127
+ const output = extractToolOutput(result);
128
+ call.error = {
129
+ message: output.split(/\r?\n/).find(Boolean) ?? "Tool call failed",
130
+ details: cloneJson(result?.details ?? null),
131
+ excerpt: truncate(output, TOOL_ERROR_EXCERPT_LIMIT),
132
+ };
133
+ }
134
+
135
+ function extractToolOutput(result) {
136
+ const content = result?.content;
137
+ if (!Array.isArray(content)) return typeof result === "string" ? result : "";
138
+ return content.filter((item) => item?.type === "text").map((item) => item.text ?? "").join("\n");
139
+ }
140
+
141
+ function cloneJson(value) {
142
+ try {
143
+ return JSON.parse(JSON.stringify(value ?? null));
144
+ } catch {
145
+ return null;
146
+ }
147
+ }
148
+
149
+ function truncate(text, limit) {
150
+ const value = String(text ?? "");
151
+ return value.length > limit ? `${value.slice(0, limit)}\n[truncated]` : value;
152
+ }
@@ -17,6 +17,7 @@ export async function runRunnerTurn({
17
17
  syncCurrentPiSidecar,
18
18
  autoNameSession,
19
19
  contextMode = "rebuild",
20
+ recordHistory = null,
20
21
  }) {
21
22
  const {
22
23
  userRecallHints = [],
@@ -80,6 +81,7 @@ export async function runRunnerTurn({
80
81
  midTurnRecallHints,
81
82
  syncCurrentPiSidecar,
82
83
  autoNameSession,
84
+ recordHistory,
83
85
  });
84
86
  return { draft: turnState.draft };
85
87
  } finally {
@@ -127,19 +129,20 @@ function logSessionEvent(logger, event) {
127
129
  });
128
130
  }
129
131
 
130
- function finalizeTurn({ prompt, userMessage, userRecallHints, currentProject, memoryStore, engine, ui, turnState, midTurnRecallHints, syncCurrentPiSidecar, autoNameSession }) {
132
+ function finalizeTurn({ prompt, userMessage, userRecallHints, currentProject, memoryStore, engine, ui, turnState, midTurnRecallHints, syncCurrentPiSidecar, autoNameSession, recordHistory }) {
131
133
  closeAssistantReply({ ui, state: turnState });
132
134
  const assistantRecallHints = flushAssistantRecall({ memoryStore, engine, turnState, currentProject });
133
135
  engine.setPendingAssistantRecallHints?.(assistantRecallHints);
134
136
  const recordedAssistantRecallHints = uniqueHints([...midTurnRecallHints, ...assistantRecallHints]);
135
137
 
136
- engine.recordTurn({
138
+ const turn = engine.recordTurn({
137
139
  userMessage: userMessage ?? prompt.slice(0, 300),
138
140
  assistantMessage: turnState.draft,
139
141
  assistantContext: compactAssistantContext(turnState),
140
142
  userRecallHints,
141
143
  assistantRecallHints: recordedAssistantRecallHints,
142
144
  });
145
+ recordHistory?.({ ...turn, thinking: assistantThinkingText(turnState), toolCalls: turnState.toolCalls });
143
146
 
144
147
  autoNameSession?.();
145
148
  syncCurrentPiSidecar();
@@ -1,8 +1,10 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
 
4
4
  const HISTORY_VERSION = 1;
5
5
  const MAX_HISTORY_ITEMS = 100;
6
+ const LOCK_WAIT_MS = 2000;
7
+ const LOCK_STALE_MS = 5000;
6
8
 
7
9
  export function createInputHistoryStore({ path, maxItems = MAX_HISTORY_ITEMS } = {}) {
8
10
  return {
@@ -20,8 +22,10 @@ export function createInputHistoryStore({ path, maxItems = MAX_HISTORY_ITEMS } =
20
22
  save(items) {
21
23
  if (!path) return;
22
24
  const normalized = normalizeItems(items, maxItems);
23
- mkdirSync(dirname(path), { recursive: true });
24
- writeFileSync(path, `${JSON.stringify({ version: HISTORY_VERSION, items: normalized }, null, 2)}\n`, "utf8");
25
+ withHistoryLock(path, () => {
26
+ const merged = mergeItems(normalized, this.load(), maxItems);
27
+ writeHistoryFile(path, merged);
28
+ });
25
29
  },
26
30
  };
27
31
  }
@@ -33,3 +37,61 @@ function normalizeItems(items, maxItems) {
33
37
  .filter(Boolean)
34
38
  .slice(0, maxItems);
35
39
  }
40
+
41
+ function mergeItems(primaryItems, existingItems, maxItems) {
42
+ const merged = [];
43
+ const seen = new Set();
44
+ for (const item of [...primaryItems, ...existingItems]) {
45
+ if (seen.has(item)) continue;
46
+ seen.add(item);
47
+ merged.push(item);
48
+ if (merged.length >= maxItems) break;
49
+ }
50
+ return merged;
51
+ }
52
+
53
+ function writeHistoryFile(path, items) {
54
+ mkdirSync(dirname(path), { recursive: true });
55
+ const tmpPath = `${path}.${process.pid}.${Date.now()}.tmp`;
56
+ const payload = `${JSON.stringify({ version: HISTORY_VERSION, items }, null, 2)}\n`;
57
+ writeFileSync(tmpPath, payload, "utf8");
58
+ renameSync(tmpPath, path);
59
+ }
60
+
61
+ function withHistoryLock(path, fn) {
62
+ mkdirSync(dirname(path), { recursive: true });
63
+ const lockPath = `${path}.lock`;
64
+ const fd = acquireLock(lockPath);
65
+ try {
66
+ return fn();
67
+ } finally {
68
+ closeSync(fd);
69
+ try { unlinkSync(lockPath); } catch {}
70
+ }
71
+ }
72
+
73
+ function acquireLock(lockPath) {
74
+ const start = Date.now();
75
+ while (true) {
76
+ try {
77
+ const fd = openSync(lockPath, "wx");
78
+ writeFileSync(fd, `${process.pid}\n`, "utf8");
79
+ return fd;
80
+ } catch (err) {
81
+ if (err?.code !== "EEXIST") throw err;
82
+ removeStaleLock(lockPath);
83
+ if (Date.now() - start >= LOCK_WAIT_MS) throw err;
84
+ sleepSync(25);
85
+ }
86
+ }
87
+ }
88
+
89
+ function removeStaleLock(lockPath) {
90
+ try {
91
+ if (Date.now() - statSync(lockPath).mtimeMs > LOCK_STALE_MS) unlinkSync(lockPath);
92
+ } catch {}
93
+ }
94
+
95
+ function sleepSync(ms) {
96
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
97
+ }
@@ -14,12 +14,13 @@ export async function runSingleShotPrompt({
14
14
  refreshStatusBar,
15
15
  modeState = null,
16
16
  }) {
17
- const turnInput = prepareTurnInput({ prompt, runner, memoryStore, currentProject, modeState });
18
- ui.writeln(turnInput.displayMessage);
19
- ui.recall?.({ source: "user", hints: turnInput.userRecallHints });
20
- if (turnInput.shouldRenderCarryoverRecall) ui.recall?.({ source: "assistant", hints: turnInput.carryoverRecallHints });
21
- refreshStatusBar.startWorking?.();
17
+ memoryStore.beginTurn();
22
18
  try {
19
+ const turnInput = prepareTurnInput({ prompt, runner, memoryStore, currentProject, modeState });
20
+ ui.writeln(turnInput.displayMessage);
21
+ ui.recall?.({ source: "user", hints: turnInput.userRecallHints });
22
+ if (turnInput.shouldRenderCarryoverRecall) ui.recall?.({ source: "assistant", hints: turnInput.carryoverRecallHints });
23
+ refreshStatusBar.startWorking?.();
23
24
  const result = await runner.runTurn(turnInput.fullPrompt, turnInput.userMessage, turnInput.runOptions);
24
25
  renderPendingAssistantRecallPreview({ runner, ui });
25
26
  await handleTurnLifecycleAction(result?.lifecycleAction, { runner, ui });
@@ -127,8 +128,9 @@ function handleInlineCommand(trimmed, { cwd, ui, lastInlineShellCommand }) {
127
128
  }
128
129
 
129
130
  async function runReplTurn({ prompt, runner, memoryStore, currentProject, ui, refreshStatusBar, setTurnRunning, modeState = null }) {
130
- const turnInput = prepareTurnInput({ prompt, runner, memoryStore, currentProject, modeState });
131
+ memoryStore.beginTurn();
131
132
  try {
133
+ const turnInput = prepareTurnInput({ prompt, runner, memoryStore, currentProject, modeState });
132
134
  ui.writeln(turnInput.displayMessage);
133
135
  ui.recall?.({ source: "user", hints: turnInput.userRecallHints });
134
136
  if (turnInput.shouldRenderCarryoverRecall) ui.recall?.({ source: "assistant", hints: turnInput.carryoverRecallHints });
@@ -1,7 +1,6 @@
1
1
  import { basename, join, resolve } from "node:path";
2
2
  import { existsSync, mkdirSync } from "node:fs";
3
3
  import { createUI } from "../ui.mjs";
4
- import { createPermissionController, MODE } from "../permissions.mjs";
5
4
  import { loadKeybindings } from "../input/keybindings.mjs";
6
5
  import { createInputHistoryStore } from "../input/history-store.mjs";
7
6
  import { createModeState } from "../input/mode-state.mjs";
@@ -12,21 +11,16 @@ import { createMarchAuthStorage } from "../../auth/storage.mjs";
12
11
  import { createRuntimeRunner } from "./create-runtime-runner.mjs";
13
12
  import { createCliShellRuntime } from "../../shell/cli-runtime.mjs";
14
13
  import { MarkdownMemoryStore } from "../../memory/markdown-store.mjs";
15
- import { createMarkdownMemoryTools } from "../../memory/markdown-tools.mjs";
16
14
  import { discoverProjectExtensionPaths } from "../../extensions/discovery.mjs";
17
15
  import { loadProjectLifecycleHookManifests } from "../../extensions/lifecycle-manifest.mjs";
18
16
  import { loadOrCreateProjectId, resumeStartupSession } from "./startup-session.mjs";
19
- import { initializeMcp } from "../../mcp/index.mjs";
20
- import { createWebToolsFromConfig } from "../../web/tools.mjs";
21
- import { createModelContextDumper } from "../../debug/model-context-dumper.mjs";
22
17
  import { createLogger, installProcessLogHandlers } from "../../debug/logger.mjs";
23
18
  import { defaultProfilePaths, ensureProfileFiles } from "../../context/profiles.mjs";
24
- import { createDesktopTurnNotifier } from "../../notification/desktop-notifier.mjs";
25
19
  import { normalizeRemoteMemorySources } from "../../memory/remote/config.mjs";
26
20
  import { resolveMemoryRoot } from "../../memory/root.mjs";
27
21
  import { ensureBrowserDaemon } from "../../browser/client/lifecycle.mjs";
28
22
 
29
- export async function createCliAppRuntime({ args, config, cwd, argv, stateRoot, useRuntimeProcess } = {}) {
23
+ export async function createCliAppRuntime({ args, config, cwd, argv, stateRoot } = {}) {
30
24
  if (!existsSync(stateRoot)) mkdirSync(stateRoot, { recursive: true });
31
25
  await ensureBrowserDaemon({ stateRoot }).catch(() => {});
32
26
 
@@ -67,23 +61,10 @@ export async function createCliAppRuntime({ args, config, cwd, argv, stateRoot,
67
61
  ensureProfileFiles(profilePaths);
68
62
  const memoryStore = new MarkdownMemoryStore({ root: memoryRoot });
69
63
  const remoteMemorySources = normalizeRemoteMemorySources(config);
70
- const memoryTools = createMarkdownMemoryTools(memoryStore, { remoteSources: remoteMemorySources });
71
64
  const currentProject = basename(cwd);
72
65
  const shellRuntime = args.shellRuntime ? createCliShellRuntime({ cwd }) : null;
73
66
 
74
- const mcpInit = useRuntimeProcess
75
- ? { clientManager: null, mcpTools: [], mcpInjections: [], errors: [] }
76
- : await initializeMcp({ projectDir: cwd });
77
- for (const { server, error } of mcpInit.errors) {
78
- if (!args.json) process.stderr.write(`[mcp] ${server}: ${error}\n`);
79
- }
80
-
81
- const webTools = createWebToolsFromConfig(config);
82
- const turnNotifier = createDesktopTurnNotifier({ enabled: Boolean(config.notifications?.turnEnd), config: config.notifications });
83
- const permissionMode = args.permissionMode ?? MODE.BYPASS;
84
- const permissionController = createPermissionController({ mode: permissionMode });
85
- const usePiSessions = true;
86
- const usePiRuntimeHost = true;
67
+ const permissionMode = args.permissionMode;
87
68
  const sessionSource = "pi";
88
69
  const sessionsRoot = join(projectMarchDir, "sessions");
89
70
  const sessionState = {
@@ -92,10 +73,6 @@ export async function createCliAppRuntime({ args, config, cwd, argv, stateRoot,
92
73
  };
93
74
  sessionState.sessionDir = join(sessionsRoot, sessionState.sessionId);
94
75
  const contextDumpRoot = resolve(projectMarchDir, "context-dumps", sessionState.sessionId);
95
- const modelContextDumper = createModelContextDumper({
96
- enabled: args.dumpContext,
97
- rootDir: contextDumpRoot,
98
- });
99
76
 
100
77
  const ui = createUI({
101
78
  json: args.json,
@@ -132,10 +109,9 @@ export async function createCliAppRuntime({ args, config, cwd, argv, stateRoot,
132
109
  let runner;
133
110
  try {
134
111
  runner = await createRuntimeRunner({
135
- useRuntimeProcess, runnerOptions, ui, memoryStore, memoryTools, shellRuntime,
136
- mcpTools: mcpInit.mcpTools, mcpInjections: mcpInit.mcpInjections, mcpClientManager: mcpInit.clientManager, webTools,
137
- usePiSessions, usePiRuntimeHost, authStorage: authConfig.authStorage,
138
- permissionController, modelContextDumper, turnNotifier, logger,
112
+ runnerOptions,
113
+ ui,
114
+ shellRuntime,
139
115
  refreshStatusBar: (...args) => refreshStatusBar?.(...args),
140
116
  });
141
117
  } catch (err) {
@@ -1,61 +1,19 @@
1
- import { createRunner } from "../../agent/runner.mjs";
2
1
  import { createRunnerProcessClient } from "../../agent/runtime/runner-process-client.mjs";
3
- import { resolvePiSessionManager } from "../../session/pi-manager.mjs";
4
2
 
5
3
  export async function createRuntimeRunner({
6
- useRuntimeProcess = false,
7
4
  runnerOptions,
8
5
  ui,
9
- memoryStore,
10
- memoryTools,
11
6
  shellRuntime,
12
- mcpTools,
13
- mcpInjections,
14
- mcpClientManager,
15
- webTools,
16
- usePiSessions,
17
- usePiRuntimeHost,
18
- authStorage,
19
- permissionController,
20
- modelContextDumper,
21
- turnNotifier,
22
- logger,
23
7
  refreshStatusBar,
24
8
  } = {}) {
25
9
  const onModelPayload = ({ estimatedTokens }) => {
26
10
  refreshStatusBar?.({ contextTokens: estimatedTokens });
27
11
  };
12
+ const onLspStatusChange = () => {
13
+ refreshStatusBar?.();
14
+ };
28
15
 
29
- const runner = useRuntimeProcess
30
- ? (await createRunnerProcessClient({ runnerOptions, ui, onModelPayload })).runner
31
- : await createRunner({
32
- ...runnerOptions,
33
- ui,
34
- memoryStore,
35
- memoryTools,
36
- shellRuntime,
37
- mcpTools,
38
- mcpInjections,
39
- mcpClientManager,
40
- webTools,
41
- sessionManager: resolvePiSessionManager({
42
- cwd: runnerOptions.cwd,
43
- projectMarchDir: runnerOptions.projectMarchDir,
44
- enabled: usePiSessions,
45
- }),
46
- useRuntimeHost: usePiRuntimeHost,
47
- syncPiSidecar: usePiSessions || usePiRuntimeHost,
48
- authStorage,
49
- maxTurns: runnerOptions.config?.maxTurns ?? undefined,
50
- trimBatch: runnerOptions.config?.trimBatch ?? undefined,
51
- hostedTools: runnerOptions.config?.hostedTools,
52
- permissionController,
53
- modelContextDumper,
54
- turnNotifier,
55
- logger,
56
- onModelPayload,
57
- });
58
-
16
+ const { runner } = await createRunnerProcessClient({ runnerOptions, ui, onModelPayload, onLspStatusChange });
59
17
  runner.shellRuntime ??= shellRuntime;
60
18
  return runner;
61
19
  }
@@ -0,0 +1,56 @@
1
+ import { matchesKey } from "@earendil-works/pi-tui";
2
+
3
+ export function createHistoryNavigationController({ editor, requestRender, isAutocompleteOpen = () => false, hasOverlay = () => false } = {}) {
4
+ let draftText = null;
5
+
6
+ return {
7
+ handleInput(data) {
8
+ if (isAutocompleteOpen() || hasOverlay()) return undefined;
9
+
10
+ if (matchesKey(data, "alt+up")) return moveWithinInput(-1);
11
+ if (matchesKey(data, "alt+down")) return moveWithinInput(1);
12
+ if (matchesKey(data, "up")) return navigateHistory(-1);
13
+ if (matchesKey(data, "down")) return navigateHistory(1);
14
+
15
+ return undefined;
16
+ },
17
+ };
18
+
19
+ function navigateHistory(direction) {
20
+ const history = Array.isArray(editor?.history) ? editor.history : [];
21
+ const currentIndex = Number.isInteger(editor?.historyIndex) ? editor.historyIndex : -1;
22
+ const nextIndex = currentIndex - direction;
23
+ if (nextIndex < -1 || nextIndex >= history.length) return undefined;
24
+
25
+ if (currentIndex === -1) draftText = editor.getText?.() ?? "";
26
+ editor.lastAction = null;
27
+ editor.historyIndex = nextIndex;
28
+ setEditorTextPreservingHistory(nextIndex === -1 ? draftText ?? "" : history[nextIndex] ?? "");
29
+ if (nextIndex === -1) draftText = null;
30
+ requestRender?.();
31
+ return { consume: true };
32
+ }
33
+
34
+ function moveWithinInput(direction) {
35
+ editor.lastAction = null;
36
+ if (direction < 0) {
37
+ if (editor.isOnFirstVisualLine?.()) editor.moveToLineStart?.();
38
+ else editor.moveCursor?.(-1, 0);
39
+ } else {
40
+ if (editor.isOnLastVisualLine?.()) editor.moveToLineEnd?.();
41
+ else editor.moveCursor?.(1, 0);
42
+ }
43
+ requestRender?.();
44
+ return { consume: true };
45
+ }
46
+
47
+ function setEditorTextPreservingHistory(text) {
48
+ if (typeof editor.setTextInternal === "function") {
49
+ editor.setTextInternal(text);
50
+ return;
51
+ }
52
+ const historyIndex = editor.historyIndex;
53
+ editor.setText?.(text);
54
+ editor.historyIndex = historyIndex;
55
+ }
56
+ }
@@ -5,7 +5,6 @@ import { formatMessageAttachmentsForDisplay } from "../../session/attachment-dis
5
5
  import { formatShellHints } from "../../shell/hints.mjs";
6
6
 
7
7
  export function prepareTurnInput({ prompt, runner, memoryStore, currentProject, modeState = null }) {
8
- memoryStore.beginTurn();
9
8
  const engine = runner.engine ?? {};
10
9
  const carryoverAlreadyRendered = engine.hasRenderedPendingAssistantRecallHints?.() ?? false;
11
10
  const carryoverRecallHints = engine.takePendingAssistantRecallHints?.() ?? [];
package/src/cli/ui.mjs CHANGED
@@ -16,6 +16,7 @@ import { showEditorSelectList } from "./tui/select/editor-select-list.mjs";
16
16
  import { StatusBar } from "./tui/status/status-bar.mjs";
17
17
  import { MainPaneLayout } from "./tui/layout/main-pane-layout.mjs";
18
18
  import { SafeRenderBoundary } from "./tui/layout/safe-render-boundary.mjs";
19
+ import { createHistoryNavigationController } from "./tui/input/history-navigation-controller.mjs";
19
20
  import { createMouseSelectionController } from "./tui/input/mouse-selection-controller.mjs";
20
21
  import { ScreenSelection } from "./tui/selection-screen.mjs";
21
22
  import { writeEditDiff } from "./tui/tui-diff-rendering.mjs";
@@ -68,6 +69,12 @@ export function createTuiUI({
68
69
  const retryStatus = createRetryStatusController({ output, requestRender, stopSpinner: spinnerStatus.stop });
69
70
  const shellDrawerControls = createShellDrawerControls({ shellDrawer, output, requestRender });
70
71
  const mouseSelectionController = createMouseSelectionController({ terminal, output, shellDrawer, shellDrawerControls, selection, writeClipboard, requestRender });
72
+ const historyNavigationController = createHistoryNavigationController({
73
+ editor,
74
+ requestRender,
75
+ isAutocompleteOpen: () => editor.isShowingAutocomplete(),
76
+ hasOverlay: () => tui.hasOverlay(),
77
+ });
71
78
 
72
79
  let onEscapeHandler = null, onCtrlCHandler = null, onShiftTabHandler = null;
73
80
  let onCtrlTHandler = null, onCtrlLHandler = null, onPasteImageHandler = null, onToggleModeHandler = null;
@@ -108,6 +115,8 @@ export function createTuiUI({
108
115
  requestRender();
109
116
  return { consume: true };
110
117
  }
118
+ const historyNavigationResult = historyNavigationController.handleInput(data);
119
+ if (historyNavigationResult) return historyNavigationResult;
111
120
  });
112
121
  terminal.write("\x1b[?1049h");
113
122
  terminal.write("\x1b[?1002h\x1b[?1006h");
@@ -71,18 +71,20 @@ export class ContextEngine {
71
71
  }
72
72
 
73
73
  recordTurn({ userMessage, assistantMessage, assistantContext = "", userRecallHints = [], assistantRecallHints = [] }) {
74
- this.turns.push({
74
+ const turn = {
75
75
  index: this.turns.length + 1,
76
76
  userMessage,
77
77
  assistantMessage: assistantMessage ?? "",
78
78
  assistantContext: assistantContext ?? "",
79
79
  userRecallHints,
80
80
  assistantRecallHints,
81
- });
81
+ };
82
+ this.turns.push(turn);
82
83
  if (this.turns.length > this.maxTurns) {
83
84
  const keep = Math.max(1, this.maxTurns - this.trimBatch);
84
85
  this.turns = this.turns.slice(-keep);
85
86
  }
87
+ return turn;
86
88
  }
87
89
 
88
90
  getRecentRecallMemoryIds() {
@@ -69,7 +69,10 @@ The user primarily asks for software engineering work: fixing bugs, adding behav
69
69
 
70
70
  <editing_contract>
71
71
  - Use read(path) for file inspection with 1-based line numbers.
72
- - Use grep(pattern), find(pattern), and ls(path) to explore the project before editing.
72
+ - Use code_search first when locating unknown implementations, responsibility boundaries, cross-module flows, or concept-level behavior.
73
+ - Use grep(pattern) and find(pattern) for exact symbol, string, filename, or call-site confirmation.
74
+ - Use ls(path) to inspect directory shape when structure matters.
75
+ - Treat code_search as a semantic map, not proof; verify important results with grep/read before editing or concluding.
73
76
  - Prefer dedicated read/search/edit tools over shell commands for file inspection and modification.
74
77
  - Use command_exec for one-shot commands. Use terminal_* only for interactive programs, long-running processes, or when preserving terminal state matters.
75
78
  - Keep the working directory stable; use paths instead of cd unless the user asks otherwise.
@@ -0,0 +1,11 @@
1
+ import { join } from "node:path";
2
+ import { HistoryStore } from "./store.mjs";
3
+
4
+ export function createRunnerHistoryStore({ stateRoot, cwd } = {}) {
5
+ if (!stateRoot) return null;
6
+ return new HistoryStore({ root: join(stateRoot, "history"), cwd });
7
+ }
8
+
9
+ export function appendRunnerTurnHistory({ store, turn, sessionStats, modelId, provider }) {
10
+ return store?.appendTurn({ turn, sessionStats, runtime: { modelId, provider } });
11
+ }
@@ -0,0 +1,129 @@
1
+ import { appendFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { createHash } from "node:crypto";
3
+ import { basename, join } from "node:path";
4
+ import { searchMarkdownRoot } from "../memory/search.mjs";
5
+
6
+ const DEFAULT_HISTORY_LIMIT = 20;
7
+ const MAX_HISTORY_LIMIT = 50;
8
+
9
+ export class HistoryStore {
10
+ constructor({ root, cwd, now = () => new Date() } = {}) {
11
+ if (!root) throw new Error("history root is required");
12
+ this.root = root;
13
+ this.cwd = cwd;
14
+ this.now = now;
15
+ }
16
+
17
+ appendTurn({ turn, sessionStats = {}, runtime = {} } = {}) {
18
+ if (!turn) return null;
19
+ const sessionId = sanitizeId(sessionStats.sessionId ?? sessionStats.sessionFile ?? "session");
20
+ const filePath = join(this.#projectDir(), `${sessionId}.md`);
21
+ mkdirSync(this.#projectDir(), { recursive: true });
22
+ if (!existsSync(filePath)) writeFileSync(filePath, this.#fileHeader({ sessionId, sessionStats }), "utf8");
23
+ appendFileSync(filePath, this.#formatTurn({ turn, sessionId, sessionStats, runtime }), "utf8");
24
+ return filePath;
25
+ }
26
+
27
+ searchRipgrep(query, { allProjects = false, sessionId = null, limit = DEFAULT_HISTORY_LIMIT, context = 2, syntax = "regex", case: caseMode = "smart" } = {}) {
28
+ const root = allProjects ? this.root : this.#projectDir();
29
+ const glob = sessionId ? [`**/${sanitizeId(sessionId)}.md`] : [];
30
+ return searchMarkdownRoot({
31
+ root,
32
+ query,
33
+ limit: clampInt(limit, 1, MAX_HISTORY_LIMIT, DEFAULT_HISTORY_LIMIT),
34
+ context,
35
+ syntax,
36
+ caseMode,
37
+ glob,
38
+ });
39
+ }
40
+
41
+ #projectDir() {
42
+ return join(this.root, "projects", cwdHash(this.cwd));
43
+ }
44
+
45
+ #fileHeader({ sessionId, sessionStats }) {
46
+ return [
47
+ `# March History · ${sessionId}`,
48
+ "",
49
+ `- cwd: ${this.cwd}`,
50
+ `- project: ${basename(this.cwd) || this.cwd}`,
51
+ sessionStats.sessionFile ? `- session_file: ${sessionStats.sessionFile}` : null,
52
+ "",
53
+ ].filter(Boolean).join("\n");
54
+ }
55
+
56
+ #formatTurn({ turn, sessionId, sessionStats, runtime }) {
57
+ const now = this.now().toISOString();
58
+ return [
59
+ `\n## Turn ${turn.index ?? "?"} · ${now}`,
60
+ "",
61
+ "metadata:",
62
+ `- session: ${sessionId}`,
63
+ `- cwd: ${this.cwd}`,
64
+ runtime.provider ? `- provider: ${runtime.provider}` : null,
65
+ runtime.modelId ? `- model: ${runtime.modelId}` : null,
66
+ sessionStats.sessionName ? `- session_name: ${sessionStats.sessionName}` : null,
67
+ "",
68
+ "### User",
69
+ safeBlock(turn.userMessage),
70
+ formatRecallSection("User memory recall", turn.userRecallHints),
71
+ "### Assistant",
72
+ safeBlock(turn.assistantMessage),
73
+ turn.thinking ? ["### Thinking", safeBlock(turn.thinking)].join("\n") : null,
74
+ "### Tool calls",
75
+ formatToolCalls(turn.toolCalls),
76
+ formatRecallSection("Assistant memory recall", turn.assistantRecallHints),
77
+ "",
78
+ ].filter(Boolean).join("\n");
79
+ }
80
+ }
81
+
82
+ function formatToolCalls(calls = []) {
83
+ if (!Array.isArray(calls) || calls.length === 0) return "(none)";
84
+ return calls.map((call) => {
85
+ const lines = [`- ${call.name ?? "unknown"} status=${call.status ?? "unknown"}`];
86
+ const args = JSON.stringify(call.args ?? null);
87
+ if (args && args !== "null") lines.push(` args: ${args}`);
88
+ if (call.status === "failed") {
89
+ lines.push(" error:");
90
+ if (call.error?.message) lines.push(` message: ${escapeSingleLine(call.error.message)}`);
91
+ if (call.error?.details) lines.push(` details: ${JSON.stringify(call.error.details)}`);
92
+ if (call.error?.excerpt) lines.push(" excerpt:", fence(call.error.excerpt));
93
+ }
94
+ return lines.join("\n");
95
+ }).join("\n");
96
+ }
97
+
98
+ function formatRecallSection(title, hints = []) {
99
+ if (!Array.isArray(hints) || hints.length === 0) return null;
100
+ return [`### ${title}`, ...hints.map((hint) => `- ${hint.id ?? "unknown"} | ${hint.name ?? hint.title ?? ""} | ${hint.description ?? ""}`)].join("\n");
101
+ }
102
+
103
+ function safeBlock(value) {
104
+ const text = String(value ?? "").trim();
105
+ return text || "(empty)";
106
+ }
107
+
108
+ function fence(value) {
109
+ return " ```text\n" + String(value ?? "").trimEnd() + "\n ```";
110
+ }
111
+
112
+ function sanitizeId(value) {
113
+ const raw = String(value ?? "session").trim() || "session";
114
+ return raw.replace(/[^a-zA-Z0-9_.-]+/g, "_").slice(0, 120) || "session";
115
+ }
116
+
117
+ function cwdHash(cwd) {
118
+ return createHash("sha1").update(String(cwd ?? "")).digest("hex").slice(0, 16);
119
+ }
120
+
121
+ function escapeSingleLine(value) {
122
+ return JSON.stringify(String(value ?? "").replace(/\s+/g, " "));
123
+ }
124
+
125
+ function clampInt(value, min, max, fallback) {
126
+ const number = Number(value);
127
+ if (!Number.isFinite(number)) return fallback;
128
+ return Math.min(max, Math.max(min, Math.floor(number)));
129
+ }
@@ -0,0 +1,39 @@
1
+ import { defineTool } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import { toolText } from "../agent/tool-result.mjs";
4
+
5
+ export function createHistorySearchTool({ store } = {}) {
6
+ if (!store) return null;
7
+ return defineTool({
8
+ name: "history_search",
9
+ label: "History Search",
10
+ description: "Search archived March turn history with ripgrep. Use it when you need details from previous sessions or earlier turns. History stores user/assistant text, visible thinking, tool call metadata, memory recall hints, and failed tool error excerpts; successful tool results are not stored.",
11
+ parameters: Type.Object({
12
+ query: Type.String({ description: "Ripgrep query/pattern to search in archived turn history" }),
13
+ allProjects: Type.Optional(Type.Boolean({ description: "Search all project histories instead of the current cwd history. Default false." })),
14
+ sessionId: Type.Optional(Type.String({ description: "Limit search to a specific session id when known" })),
15
+ syntax: Type.Optional(Type.Union([Type.Literal("regex"), Type.Literal("literal")], { description: "Pattern syntax. Default: regex" })),
16
+ case: Type.Optional(Type.Union([Type.Literal("smart"), Type.Literal("sensitive"), Type.Literal("insensitive")], { description: "Case matching mode. Default: smart" })),
17
+ context: Type.Optional(Type.Number({ description: "Context lines around each match. Default: 2" })),
18
+ limit: Type.Optional(Type.Number({ description: "Maximum matches to return. Default: 20, max: 50" })),
19
+ }),
20
+ execute: async (_toolCallId, params) => {
21
+ try {
22
+ const results = store.searchRipgrep(params.query, params);
23
+ if (results.length === 0) return toolText(`history_search found no matches for ${JSON.stringify(params.query)}.`);
24
+ return toolText(formatHistorySearchResults(results), { results });
25
+ } catch (err) {
26
+ return toolText(`Error: ${err.message}`, { error: true });
27
+ }
28
+ },
29
+ });
30
+ }
31
+
32
+ function formatHistorySearchResults(results) {
33
+ const lines = [`history_search found ${results.length} match${results.length === 1 ? "" : "es"}.`];
34
+ results.forEach((result, index) => {
35
+ lines.push("", `[${index + 1}] ${result.path}:${result.line}`);
36
+ if (result.excerpt?.text) lines.push(result.excerpt.text);
37
+ });
38
+ return lines.join("\n");
39
+ }
@@ -73,13 +73,14 @@ export function languageIdForPath(path) {
73
73
  }
74
74
 
75
75
  export class LspClient {
76
- constructor({ serverId, command, args = [], cwd, initialization = {}, store }) {
76
+ constructor({ serverId, command, args = [], cwd, initialization = {}, store, onStatusChange = null }) {
77
77
  this.serverId = serverId;
78
78
  this.command = command;
79
79
  this.args = args;
80
80
  this.cwd = cwd;
81
81
  this.initialization = initialization;
82
82
  this.store = store;
83
+ this.onStatusChange = onStatusChange;
83
84
  this.status = "starting";
84
85
  this.process = null;
85
86
  this.buffer = Buffer.alloc(0);
@@ -98,7 +99,7 @@ export class LspClient {
98
99
  });
99
100
  this.process.stdout.on("data", (chunk) => this.#onData(chunk));
100
101
  this.process.on("exit", () => {
101
- this.status = "failed";
102
+ this.#setStatus("failed");
102
103
  for (const pending of this.pending.values()) pending.reject(new Error("LSP exited"));
103
104
  this.pending.clear();
104
105
  });
@@ -124,7 +125,7 @@ export class LspClient {
124
125
  }), INITIALIZE_TIMEOUT_MS);
125
126
  this.syncKind = getSyncKind(initialized?.capabilities);
126
127
  this.#notify("initialized", {});
127
- this.status = "ready";
128
+ this.#setStatus("ready");
128
129
  }
129
130
 
130
131
  touchFile(path) {
@@ -132,7 +133,7 @@ export class LspClient {
132
133
  const text = readFileSync(path, "utf8");
133
134
  const uri = pathToFileURL(path).href;
134
135
  const existing = this.documents.get(path);
135
- this.status = "busy";
136
+ this.#setStatus("busy");
136
137
  if (existing) {
137
138
  const version = existing.version + 1;
138
139
  this.documents.set(path, { version, text });
@@ -199,7 +200,7 @@ export class LspClient {
199
200
  uri: message.params?.uri,
200
201
  diagnostics: message.params?.diagnostics ?? [],
201
202
  });
202
- this.status = "idle";
203
+ this.#setStatus("idle");
203
204
  return;
204
205
  }
205
206
 
@@ -208,6 +209,12 @@ export class LspClient {
208
209
  }
209
210
  }
210
211
 
212
+ #setStatus(status) {
213
+ if (this.status === status) return;
214
+ this.status = status;
215
+ this.onStatusChange?.({ id: this.serverId, root: this.cwd, status });
216
+ }
217
+
211
218
  #requestResult(method) {
212
219
  if (method === "workspace/configuration") return [];
213
220
  if (method === "workspace/workspaceFolders") return [{ name: "workspace", uri: pathToFileURL(this.cwd).href }];
@@ -3,9 +3,10 @@ import { LspDiagnosticStore } from "./diagnostic-store.mjs";
3
3
  import { resolveLspServerStatus } from "./servers.mjs";
4
4
 
5
5
  export class LspService {
6
- constructor({ cwd, onEvent = null }) {
6
+ constructor({ cwd, onEvent = null, onStatusChange = null }) {
7
7
  this.cwd = cwd;
8
8
  this.onEvent = onEvent;
9
+ this.onStatusChange = onStatusChange;
9
10
  this.store = new LspDiagnosticStore();
10
11
  this.clients = new Map();
11
12
  this.spawning = new Map();
@@ -19,6 +20,7 @@ export class LspService {
19
20
  if (result.status === "unavailable") {
20
21
  this.unavailable.set(result.id, result);
21
22
  this.#emitOnce(`unavailable:${result.id}:${result.reason}`, result);
23
+ this.#emitStatusChange(result);
22
24
  return result;
23
25
  }
24
26
 
@@ -34,7 +36,9 @@ export class LspService {
34
36
  return { status: "starting", id: server.id, root: server.root };
35
37
  }
36
38
 
37
- this.#emitOnce(`starting:${key}`, { status: "starting", id: server.id, root: server.root, managed: server.managed });
39
+ const startingEvent = { status: "starting", id: server.id, root: server.root, managed: server.managed };
40
+ this.#emitOnce(`starting:${key}`, startingEvent);
41
+ this.#emitStatusChange(startingEvent);
38
42
  const task = this.#startClient(server, key).then((client) => {
39
43
  client?.touchFile(path);
40
44
  return client;
@@ -76,18 +80,22 @@ export class LspService {
76
80
  cwd: server.root,
77
81
  initialization: server.initialization,
78
82
  store: this.store,
83
+ onStatusChange: (event) => this.#emitStatusChange(event),
79
84
  });
80
85
  try {
81
86
  await client.start();
82
87
  this.clients.set(key, client);
83
88
  this.unavailable.delete(server.id);
84
- this.#emitOnce(`attached:${key}`, { status: "attached", id: server.id, root: server.root, managed: server.managed });
89
+ const attachedEvent = { status: "attached", id: server.id, root: server.root, managed: server.managed };
90
+ this.#emitOnce(`attached:${key}`, attachedEvent);
91
+ this.#emitStatusChange(attachedEvent);
85
92
  return client;
86
93
  } catch (err) {
87
94
  client.status = "failed";
88
95
  const event = { status: "failed", id: server.id, root: server.root, reason: err.message };
89
96
  this.unavailable.set(server.id, event);
90
97
  this.#emitOnce(`failed:${key}:${err.message}`, event);
98
+ this.#emitStatusChange(event);
91
99
  return null;
92
100
  }
93
101
  }
@@ -97,6 +105,10 @@ export class LspService {
97
105
  this.announced.add(key);
98
106
  this.onEvent?.(event);
99
107
  }
108
+
109
+ #emitStatusChange(event) {
110
+ this.onStatusChange?.(event);
111
+ }
100
112
  }
101
113
 
102
114
  function summarizeStatus(servers) {
package/src/main.mjs CHANGED
@@ -26,13 +26,12 @@ export async function run(argv) {
26
26
 
27
27
  const config = loadConfig(cwd);
28
28
  const stateRoot = join(homedir(), ".march");
29
- const useRuntimeProcess = process.env.MARCH_RUNTIME_PROCESS !== "0";
30
29
  installNetworkEnvironment(config.network);
31
30
 
32
31
  const earlyCommand = await runEarlyCliCommand(args, { config, cwd, stateRoot });
33
32
  if (earlyCommand.handled) return earlyCommand.code;
34
33
 
35
- const app = await createCliAppRuntime({ args, config, cwd, argv, stateRoot, useRuntimeProcess });
34
+ const app = await createCliAppRuntime({ args, config, cwd, argv, stateRoot });
36
35
  if (!app.ok) return app.code;
37
36
 
38
37
  const gatewayDaemonCommand = await maybeRunGatewayDaemonCommand(args, {
@@ -6,11 +6,11 @@ import { createWebSessionManager, resolveWorkspace } from "./session-manager.mjs
6
6
  const DEFAULT_HOST = "127.0.0.1";
7
7
  const DEFAULT_PORT = 4174;
8
8
 
9
- export async function runWebUiCommand(args, { config, cwd, stateRoot, useRuntimeProcess = true } = {}) {
9
+ export async function runWebUiCommand(args, { config, cwd, stateRoot } = {}) {
10
10
  const host = args.host ?? DEFAULT_HOST;
11
11
  assertLoopbackHost(host);
12
12
  const port = Number.parseInt(args.port ?? "", 10) || DEFAULT_PORT;
13
- const runtime = createWebSessionManager({ args, config, launchCwd: cwd, stateRoot, useRuntimeProcess });
13
+ const runtime = createWebSessionManager({ args, config, launchCwd: cwd, stateRoot });
14
14
  const initialWorkspace = resolveInitialWorkspace(args, cwd);
15
15
  if (initialWorkspace) await runtime.createSession(initialWorkspace);
16
16
 
@@ -3,17 +3,12 @@ import { basename, join, resolve } from "node:path";
3
3
  import { existsSync, mkdirSync, readdirSync, statSync } from "node:fs";
4
4
  import { createMarchAuthStorage } from "../auth/storage.mjs";
5
5
  import { createRuntimeRunner } from "../cli/startup/create-runtime-runner.mjs";
6
- import { createPermissionController, MODE } from "../cli/permissions.mjs";
7
6
  import { createCliShellRuntime } from "../shell/cli-runtime.mjs";
8
7
  import { MarkdownMemoryStore } from "../memory/markdown-store.mjs";
9
- import { createMarkdownMemoryTools } from "../memory/markdown-tools.mjs";
10
8
  import { resolveMemoryRoot } from "../memory/root.mjs";
11
9
  import { defaultProfilePaths, ensureProfileFiles } from "../context/profiles.mjs";
12
10
  import { loadOrCreateProjectId } from "../cli/startup/startup-session.mjs";
13
- import { createWebToolsFromConfig } from "../web/tools.mjs";
14
11
  import { createLogger, installProcessLogHandlers } from "../debug/logger.mjs";
15
- import { createModelContextDumper } from "../debug/model-context-dumper.mjs";
16
- import { createDesktopTurnNotifier } from "../notification/desktop-notifier.mjs";
17
12
  import { discoverProjectExtensionPaths } from "../extensions/discovery.mjs";
18
13
  import { loadProjectLifecycleHookManifests } from "../extensions/lifecycle-manifest.mjs";
19
14
  import { normalizeRemoteMemorySources } from "../memory/remote/config.mjs";
@@ -22,7 +17,7 @@ import { prepareTurnInput } from "../cli/turn/turn-input-preparer.mjs";
22
17
  const MAX_WORKSPACE_DEPTH = 3;
23
18
  const MAX_WORKSPACE_ENTRIES = 200;
24
19
 
25
- export async function createWebRuntimeHost({ args, config, cwd, stateRoot, useRuntimeProcess = true } = {}) {
20
+ export async function createWebRuntimeHost({ args, config, cwd, stateRoot } = {}) {
26
21
  stateRoot ??= join(homedir(), ".march");
27
22
  if (!existsSync(stateRoot)) mkdirSync(stateRoot, { recursive: true });
28
23
  const logger = createLogger({ logDir: join(stateRoot, "logs") });
@@ -40,14 +35,10 @@ export async function createWebRuntimeHost({ args, config, cwd, stateRoot, useRu
40
35
 
41
36
  const memoryStore = new MarkdownMemoryStore({ root: memoryRoot });
42
37
  const remoteMemorySources = normalizeRemoteMemorySources(config);
43
- const memoryTools = createMarkdownMemoryTools(memoryStore, { remoteSources: remoteMemorySources });
44
38
  const shellRuntime = args.shellRuntime ? createCliShellRuntime({ cwd }) : null;
45
39
  const extensionPaths = discoverProjectExtensionPaths(cwd);
46
40
  const lifecycleManifests = loadProjectLifecycleHookManifests(cwd);
47
41
  const contextDumpRoot = resolve(projectMarchDir, "context-dumps", Date.now().toString(36));
48
- const modelContextDumper = createModelContextDumper({ enabled: args.dumpContext, rootDir: contextDumpRoot });
49
- const permissionController = createPermissionController({ mode: args.permissionMode ?? MODE.BYPASS });
50
- const turnNotifier = createDesktopTurnNotifier({ enabled: Boolean(config.notifications?.turnEnd), config: config.notifications });
51
42
  const ui = createHeadlessWebUi();
52
43
  const currentProject = basename(cwd);
53
44
  const namespace = loadOrCreateProjectId(projectMarchDir);
@@ -72,20 +63,9 @@ export async function createWebRuntimeHost({ args, config, cwd, stateRoot, useRu
72
63
  remoteMemorySources,
73
64
  };
74
65
  const runner = await createRuntimeRunner({
75
- useRuntimeProcess,
76
66
  runnerOptions,
77
67
  ui,
78
- memoryStore,
79
- memoryTools,
80
68
  shellRuntime,
81
- webTools: createWebToolsFromConfig(config),
82
- usePiSessions: true,
83
- usePiRuntimeHost: true,
84
- authStorage: authConfig.authStorage,
85
- permissionController,
86
- modelContextDumper,
87
- turnNotifier,
88
- logger,
89
69
  });
90
70
  let turnRunning = false;
91
71
 
@@ -100,9 +80,10 @@ export async function createWebRuntimeHost({ args, config, cwd, stateRoot, useRu
100
80
  async runTurn(prompt) {
101
81
  if (turnRunning) throw new Error("A turn is already running");
102
82
  turnRunning = true;
103
- const input = prepareTurnInput({ prompt, runner, memoryStore, currentProject });
104
- runner.runtimeUiEvents.emit({ type: "web_user_message", text: input.userMessage });
83
+ memoryStore.beginTurn();
105
84
  try {
85
+ const input = prepareTurnInput({ prompt, runner, memoryStore, currentProject });
86
+ runner.runtimeUiEvents.emit({ type: "web_user_message", text: input.userMessage });
106
87
  return await runner.runTurn(input.fullPrompt, input.userMessage, input.runOptions);
107
88
  } finally {
108
89
  turnRunning = false;
@@ -3,7 +3,7 @@ import { homedir } from "node:os";
3
3
  import { basename, resolve } from "node:path";
4
4
  import { createWebRuntimeHost } from "./runtime-host.mjs";
5
5
 
6
- export function createWebSessionManager({ args, config, launchCwd, stateRoot, useRuntimeProcess = true } = {}) {
6
+ export function createWebSessionManager({ args, config, launchCwd, stateRoot } = {}) {
7
7
  const sessions = new Map();
8
8
  const activities = [];
9
9
  let activeSessionId = null;
@@ -12,7 +12,7 @@ export function createWebSessionManager({ args, config, launchCwd, stateRoot, us
12
12
  async function createSession(workspacePath) {
13
13
  const workspace = resolveWorkspace(workspacePath, launchCwd);
14
14
  const id = `session-${Date.now().toString(36)}-${nextSessionNumber++}`;
15
- const runtime = await createWebRuntimeHost({ args, config, cwd: workspace, stateRoot, useRuntimeProcess });
15
+ const runtime = await createWebRuntimeHost({ args, config, cwd: workspace, stateRoot });
16
16
  const session = { id, workspace, title: basename(workspace) || workspace, runtime, createdAt: Date.now() };
17
17
  sessions.set(id, session);
18
18
  activeSessionId = id;