march-cli 0.1.8 → 0.1.10

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.8",
3
+ "version": "0.1.10",
4
4
  "description": "March CLI — terminal-native coding agent with context reconstruction",
5
5
  "type": "module",
6
6
  "main": "./src/main.mjs",
@@ -0,0 +1,69 @@
1
+ import { formatLspDiagnosticsForPath } from "../../lsp/diagnostics-format.mjs";
2
+ import { sameLspPath } from "../../lsp/path-match.mjs";
3
+
4
+ export async function waitForLspReport({ lspService, path, lspResult, since = Date.now(), timeoutMs = 3000, intervalMs = 150 }) {
5
+ const immediate = formatLspResultMessage(lspResult);
6
+ if (!lspService?.snapshot || !path || lspResult?.status === "unsupported") return immediate;
7
+ if (lspResult?.status === "unavailable" || lspResult?.status === "failed") return immediate;
8
+
9
+ const deadline = Date.now() + timeoutMs;
10
+ for (;;) {
11
+ const snapshot = lspService.snapshot();
12
+ const diagnostics = formatCurrentLspDiagnosticsForPath({ snapshot, path, since });
13
+ if (diagnostics) return diagnostics;
14
+ if (hasCurrentDiagnosticPublish({ snapshot, path, since })) return formatNoLspDiagnostics({ snapshot });
15
+
16
+ const remaining = deadline - Date.now();
17
+ if (remaining <= 0) return formatLatestLspMessage({ snapshot, lspResult });
18
+ await sleep(Math.min(intervalMs, remaining));
19
+ }
20
+ }
21
+
22
+ function formatLspResultMessage(result, { timedOut = false } = {}) {
23
+ if (!result || result.status === "unsupported") return "";
24
+ if (result.status === "unavailable" || result.status === "failed") {
25
+ return `<lsp status="${result.status}" server="${result.id ?? "unknown"}">${result.reason ?? "unavailable"}</lsp>`;
26
+ }
27
+ if (result.status === "starting") {
28
+ const detail = timedOut ? "diagnostics still pending; server continues in background" : "diagnostics pending";
29
+ return `<lsp status="starting" server="${result.id}">${detail}</lsp>`;
30
+ }
31
+ return "";
32
+ }
33
+
34
+ function formatCurrentLspDiagnosticsForPath({ snapshot, path, since }) {
35
+ const diagnostics = formatLspDiagnosticsForPath({ snapshot, path });
36
+ if (!diagnostics) return "";
37
+ if (!Array.isArray(snapshot?.files)) return diagnostics;
38
+ return hasCurrentDiagnosticPublish({ snapshot, path, since }) ? diagnostics : "";
39
+ }
40
+
41
+ function hasCurrentDiagnosticPublish({ snapshot, path, since }) {
42
+ return (snapshot?.files ?? []).some((file) => sameLspPath(file.path, path) && (file.updatedAt ?? 0) >= since);
43
+ }
44
+
45
+ function formatNoLspDiagnostics({ snapshot }) {
46
+ const lines = ["[diagnostics]", "source: lsp"];
47
+ if (snapshot?.status) lines.push(`status: ${snapshot.status}`);
48
+ lines.push("summary: 0 diagnostics");
49
+ return lines.join("\n");
50
+ }
51
+
52
+ function formatLatestLspMessage({ snapshot, lspResult }) {
53
+ const server = latestServerForResult(snapshot, lspResult);
54
+ if (server?.status === "failed" || server?.status === "unavailable") {
55
+ return formatLspResultMessage({ ...lspResult, ...server });
56
+ }
57
+ if (server?.status === "idle" || server?.status === "ready") return formatNoLspDiagnostics({ snapshot });
58
+ return formatLspResultMessage(lspResult, { timedOut: lspResult?.status === "starting" });
59
+ }
60
+
61
+ function latestServerForResult(snapshot, lspResult) {
62
+ const servers = snapshot?.servers ?? [];
63
+ return servers.find((server) => server.id === lspResult?.id && server.root === lspResult?.root)
64
+ ?? servers.find((server) => server.id === lspResult?.id);
65
+ }
66
+
67
+ function sleep(ms) {
68
+ return new Promise((resolve) => setTimeout(resolve, ms));
69
+ }
@@ -5,7 +5,7 @@ import { Type } from "typebox";
5
5
  import { toolText } from "./tool-result.mjs";
6
6
  import { applyReplaceTextPatch, applyReplaceRangePatch } from "./editing/diff-apply.mjs";
7
7
  import { formatAppliedDiff, formatDiff } from "./editing/diff-format.mjs";
8
- import { buildDiagnosticsForPath } from "../context/diagnostics.mjs";
8
+ import { waitForLspReport } from "./editing/lsp-report.mjs";
9
9
 
10
10
  export { formatDiff } from "./editing/diff-format.mjs";
11
11
 
@@ -72,10 +72,11 @@ async function writeFullFile({ absPath, path, content, mode, engine, ui, lspServ
72
72
  try {
73
73
  mkdirSync(dirname(absPath), { recursive: true });
74
74
  writeFileSync(absPath, content, "utf8");
75
- lspService?.touchFile?.(absPath);
75
+ const lspTouchedAt = Date.now();
76
+ const lspResult = await lspService?.touchFile?.(absPath);
76
77
  ui.editDiff(absPath, diffLines);
77
78
 
78
- return await toolTextWithDiagnostics(`${mode === WRITE_MODE ? "Wrote" : "Overwrote"} ${path}\n\n${formatAppliedDiff([{ oldText, newText: content, startLine: 1 }])}`, { path: absPath }, { lspService, path: absPath });
79
+ return await toolTextWithDiagnostics(`${mode === WRITE_MODE ? "Wrote" : "Overwrote"} ${path}\n\n${formatAppliedDiff([{ oldText, newText: content, startLine: 1 }])}`, { path: absPath }, { lspService, path: absPath, lspResult, since: lspTouchedAt });
79
80
  } catch (err) {
80
81
  return toolText(`Error writing ${absPath}: ${err.message}`, { error: true });
81
82
  }
@@ -100,33 +101,18 @@ async function patchFile({ absPath, path, edits, engine, ui, lspService }) {
100
101
  try {
101
102
  mkdirSync(dirname(absPath), { recursive: true });
102
103
  writeFileSync(absPath, newContent, "utf8");
103
- lspService?.touchFile?.(absPath);
104
+ const lspTouchedAt = Date.now();
105
+ const lspResult = await lspService?.touchFile?.(absPath);
104
106
  ui.editDiff(absPath, prepared.edits.flatMap((edit) => formatDiff(edit.oldText, edit.newText, { startLine: edit.startLine })));
105
- return await toolTextWithDiagnostics(`Edited ${absPath}\n\n${formatAppliedDiff(prepared.edits)}`, { path: absPath, edits: prepared.edits.length }, { lspService, path: absPath });
107
+ return await toolTextWithDiagnostics(`Edited ${absPath}\n\n${formatAppliedDiff(prepared.edits)}`, { path: absPath, edits: prepared.edits.length }, { lspService, path: absPath, lspResult, since: lspTouchedAt });
106
108
  } catch (err) {
107
109
  return toolText(`Error writing ${absPath}: ${err.message}`, { error: true });
108
110
  }
109
111
  }
110
112
 
111
- async function toolTextWithDiagnostics(text, details, { lspService, path, timeoutMs = 3000, intervalMs = 150 } = {}) {
112
- const diagnostics = await waitForDiagnosticsForPath({ lspService, path, timeoutMs, intervalMs });
113
- return toolText(diagnostics ? `${text}\n\n${diagnostics}` : text, details);
114
- }
115
-
116
- async function waitForDiagnosticsForPath({ lspService, path, timeoutMs, intervalMs }) {
117
- if (!lspService?.snapshot || !path) return "";
118
- const deadline = Date.now() + timeoutMs;
119
- for (;;) {
120
- const diagnostics = buildDiagnosticsForPath({ snapshot: lspService.snapshot(), path });
121
- if (diagnostics) return diagnostics;
122
- const remaining = deadline - Date.now();
123
- if (remaining <= 0) return "";
124
- await sleep(Math.min(intervalMs, remaining));
125
- }
126
- }
127
-
128
- function sleep(ms) {
129
- return new Promise((resolve) => setTimeout(resolve, ms));
113
+ async function toolTextWithDiagnostics(text, details, { lspService, path, lspResult, since = Date.now(), timeoutMs = 3000, intervalMs = 150 } = {}) {
114
+ const lspReport = await waitForLspReport({ lspService, path, lspResult, since, timeoutMs, intervalMs });
115
+ return toolText(lspReport ? `${text}\n\n${lspReport}` : text, details);
130
116
  }
131
117
 
132
118
  export function preparePatchEdits(content, edits, path = "file") {
@@ -170,24 +170,31 @@ function formatContentPart(part) {
170
170
  }
171
171
 
172
172
  function formatToolSummary(tool) {
173
- const name = tool?.function?.name ?? tool?.name ?? "unnamed_tool";
173
+ const name = tool?.function?.name ?? tool?.name ?? tool?.type ?? googleToolName(tool) ?? "unnamed_tool";
174
174
  const description = tool?.function?.description ?? tool?.description ?? "";
175
175
  return description ? `${name}: ${description}` : name;
176
176
  }
177
177
 
178
178
  function extractPayloadTools(payload) {
179
+
179
180
  if (!payload || typeof payload !== "object") return null;
180
181
  if (Array.isArray(payload.tools)) return payload.tools;
181
- if (payload.body && typeof payload.body === "object" && Array.isArray(payload.body.tools)) return payload.body.tools;
182
+ if (Array.isArray(payload.config?.tools)) return payload.config.tools;
183
+ if (payload.body && typeof payload.body === "object") return extractPayloadTools(payload.body);
182
184
  if (typeof payload.body === "string") {
183
185
  try {
184
- const body = JSON.parse(payload.body);
185
- if (Array.isArray(body.tools)) return body.tools;
186
+ return extractPayloadTools(JSON.parse(payload.body));
186
187
  } catch {}
187
188
  }
188
189
  return null;
189
190
  }
190
191
 
192
+ function googleToolName(tool) {
193
+ if (tool?.googleSearch) return "googleSearch";
194
+ if (tool?.googleSearchRetrieval) return "googleSearchRetrieval";
195
+ return null;
196
+ }
197
+
191
198
  function textChars(value) {
192
199
  if (value == null) return 0;
193
200
  if (typeof value === "string") return value.length;
@@ -0,0 +1,18 @@
1
+ export function providerContextToPayload(providerContext) {
2
+ return {
3
+ messages: [
4
+ { role: "system", content: providerContext.system },
5
+ ...(providerContext.userMessages ?? []).map((message) => ({ role: "user", content: message.content })),
6
+ ],
7
+ };
8
+ }
9
+
10
+ export async function notifyTurnEndBestEffort(turnNotifier, event) {
11
+ if (!turnNotifier?.notifyTurnEnd) return { ok: false, reason: "not-configured", results: [] };
12
+ try {
13
+ return await turnNotifier.notifyTurnEnd(event);
14
+ } catch (err) {
15
+ // Notification must never change turn behavior.
16
+ return { ok: false, reason: err?.message ?? String(err), results: [] };
17
+ }
18
+ }
@@ -8,27 +8,29 @@ import { ContextEngine } from "../context/engine.mjs";
8
8
  import { createMarchLifecycleAdapter } from "../extensions/lifecycle-adapter.mjs";
9
9
  import { syncPiSessionSidecar } from "../session/sidecar-sync.mjs";
10
10
  import { LspService } from "../lsp/service.mjs";
11
+ import { formatLspServiceEvent } from "../lsp/status-message.mjs";
11
12
  import { formatRecallHints } from "../memory/markdown-store.mjs";
12
13
  import { appendProviderUserMessage, estimateProviderPayloadTokens, installModelPayloadDumper, replaceProviderContextMessages } from "./model-payload-dumper.mjs";
13
14
  import { resolveInitialModel, resolveRunnerSessionManager } from "./runner/runner-init.mjs";
14
15
  import { runRunnerCleanup } from "./runner/runner-cleanup.mjs";
15
16
  import { createRunnerRuntimeHost } from "./runtime/runner-runtime-host.mjs";
16
17
  import { getRunnerSessionStats, syncEngineSessionState } from "./runner/runner-session-state.mjs";
18
+ import { notifyTurnEndBestEffort, providerContextToPayload } from "./runner/runner-utils.mjs";
17
19
  import { resolveRunnerSessionOptions } from "./session/session-options.mjs";
18
20
  import { createSessionBinding } from "./session/session-binding.mjs";
19
21
  import { maybeAutoNameSession } from "./session/session-auto-name.mjs";
20
22
  import { MARCH_BASE_TOOL_NAMES } from "./tool-names.mjs";
21
23
  import { runRunnerTurn } from "./turn/turn-runner.mjs";
24
+ import { beginLoggedTurn } from "./turn/turn-logging.mjs";
22
25
  import { appendFastVariants, createFastModelEntry, fromFastEntryModel, isFastProvider } from "./runner/fast-model.mjs";
23
26
  import { registerSuperGrokProvider } from "../supergrok/provider.mjs";
24
27
  import { registerCustomProviders } from "../provider/custom-provider.mjs";
25
-
28
+ import { injectHostedTools } from "../provider/hosted-tools.mjs";
26
29
  export { MARCH_BASE_TOOL_NAMES };
27
30
  export { installModelPayloadDumper } from "./model-payload-dumper.mjs";
28
31
  export { createDefaultSessionManager, resolveRunnerSessionManager } from "./runner/runner-init.mjs";
29
32
  export { getRunnerSessionStats, syncEngineSessionState } from "./runner/runner-session-state.mjs";
30
-
31
- export async function createRunner({ cwd, modelId = null, provider = null, providers = {}, stateRoot, ui, memoryRoot = null, centerMemoryPath = null, memoryStore = null, memoryTools = [], 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, onModelPayload = null, createAgentSessionImpl = createAgentSession, createAgentSessionRuntimeImpl, createRuntimeServices, createRuntimeSessionFromServices, maxTurns, trimBatch, serviceTier = null }) {
33
+ export async function createRunner({ cwd, modelId = null, provider = null, providers = {}, stateRoot, ui, memoryRoot = null, centerMemoryPath = null, memoryStore = null, memoryTools = [], 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 = {} }) {
32
34
  if (!useRuntimeHost && extensionPaths.length > 0) {
33
35
  throw new Error("--extension requires the default pi runtime host path");
34
36
  }
@@ -48,11 +50,11 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
48
50
  compaction: { enabled: false },
49
51
  retry: { enabled: true, maxRetries: 3, baseDelayMs: 2000 },
50
52
  });
51
- const lspService = new LspService({ cwd });
53
+ const lspService = new LspService({ cwd, onEvent: (event) => ui.status?.(formatLspServiceEvent(event)) });
52
54
  const engine = new ContextEngine({ cwd, modelId, provider, namespace, memoryRoot, centerMemoryPath, shellRuntime, lspService, injections: mcpInjections, maxTurns, trimBatch });
53
55
  const resolvedSessionManager = resolveRunnerSessionManager(cwd, sessionManager);
54
56
  const sessionBinding = createSessionBinding(null);
55
- let currentModelCallKind = "model";
57
+ let currentModelCallKind = "model", currentTurnId = null;
56
58
  let currentPromptForContext = "";
57
59
  let currentTurnContextMode = "rebuild";
58
60
  let nextTurnContextMode = "rebuild";
@@ -69,9 +71,9 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
69
71
  sessionManager: resolvedSessionManager, sessionBinding, engine, ui,
70
72
  projectMarchDir,
71
73
  memoryTools, memoryStore, shellRuntime, lspService, mcpTools, webTools,
72
- permissionController, extensionPaths,
74
+ permissionController, extensionPaths, hostedTools,
73
75
  onRebind: (session) => {
74
- installModelPayloadDumper(session, modelContextDumper, () => currentModelCallKind, onModelPayload, injectMarchSystemContext);
76
+ installModelPayloadDumper(session, modelContextDumper, () => currentModelCallKind, onLoggedModelPayload, injectMarchSystemContext);
75
77
  syncEngineSessionState(engine, session);
76
78
  },
77
79
  createAgentSessionRuntimeImpl,
@@ -90,7 +92,7 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
90
92
  sessionManager: resolvedSessionManager, settingsManager,
91
93
  });
92
94
  sessionBinding.set(session);
93
- installModelPayloadDumper(session, modelContextDumper, () => currentModelCallKind, onModelPayload, injectMarchSystemContext);
95
+ installModelPayloadDumper(session, modelContextDumper, () => currentModelCallKind, onLoggedModelPayload, injectMarchSystemContext);
94
96
  }
95
97
  syncEngineSessionState(engine, sessionBinding.get());
96
98
  lifecycleAdapter = createMarchLifecycleAdapter({
@@ -114,11 +116,14 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
114
116
  nextTurnContextMode = "rebuild";
115
117
  pendingMidTurnRecallHints = [];
116
118
  const turnStartedAt = Date.now();
119
+ const turnLog = beginLoggedTurn({ logger, engine, modelId, provider, contextMode, userMessage, userRecallHints, startedAt: turnStartedAt }); currentTurnId = turnLog.turnId;
117
120
  try {
118
121
  const result = await runRunnerTurn({
119
122
  prompt, userMessage, options: { userRecallHints, currentProject },
120
123
  sessionBinding, engine, ui, projectMarchDir, memoryStore,
121
124
  setModelCallKind: (kind) => { currentModelCallKind = kind; },
125
+ logger: turnLog.logger,
126
+ setPhase: turnLog.setPhase,
122
127
  onMidTurnRecallHints: (hints) => { pendingMidTurnRecallHints.push(...hints); },
123
128
  syncCurrentPiSidecar,
124
129
  autoNameSession,
@@ -130,6 +135,7 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
130
135
  draft: result?.draft ?? "",
131
136
  durationMs: Date.now() - turnStartedAt,
132
137
  });
138
+ turnLog.endSuccess(result);
133
139
  return result;
134
140
  } catch (err) {
135
141
  lastNotificationResult = await notifyTurnEndBestEffort(turnNotifier, {
@@ -138,8 +144,10 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
138
144
  errorMessage: err?.message ?? String(err),
139
145
  durationMs: Date.now() - turnStartedAt,
140
146
  });
147
+ turnLog.endError(err);
141
148
  throw err;
142
149
  } finally {
150
+ currentTurnId = null;
143
151
  currentTurnContextMode = "rebuild";
144
152
  }
145
153
  },
@@ -218,6 +226,7 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
218
226
  },
219
227
  getExtensionDiagnostics() { return runtimeHost?.getDiagnostics?.() ?? []; },
220
228
  getExtensionLifecycleState() { return lifecycleAdapter.getState(); },
229
+ getLspStatus() { return lspService.snapshot(); },
221
230
  async switchPiSession(sessionPath) {
222
231
  if (!runtimeHost) throw new Error("pi runtime host is not enabled");
223
232
  nextTurnContextMode = "rebuild";
@@ -263,11 +272,22 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
263
272
  },
264
273
  });
265
274
  }
266
- function injectMarchSystemContext(payload, { kind } = {}) {
275
+ function onLoggedModelPayload(event) {
276
+ logger?.event("model.payload", {
277
+ kind: event.kind,
278
+ provider: event.model?.provider,
279
+ model: event.model?.id,
280
+ estimatedTokens: event.estimatedTokens,
281
+ turnId: currentTurnId,
282
+ });
283
+ onModelPayload?.(event);
284
+ }
285
+ function injectMarchSystemContext(payload, { kind, model } = {}) {
267
286
  if (kind !== "user") return payload;
268
287
  let nextPayload = currentTurnContextMode === "continueExistingPiTranscript"
269
288
  ? payload
270
289
  : replaceProviderContextMessages(payload, engine.buildProviderContext(currentPromptForContext));
290
+ nextPayload = injectHostedTools(nextPayload, model, hostedTools);
271
291
  if (_currentFastEntry) nextPayload = { ...nextPayload, service_tier: "priority" };
272
292
  if (pendingMidTurnRecallHints.length > 0) {
273
293
  nextPayload = appendProviderUserMessage(nextPayload, formatRecallHints("assistant", pendingMidTurnRecallHints));
@@ -276,22 +296,3 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
276
296
  return nextPayload;
277
297
  }
278
298
  }
279
-
280
- function providerContextToPayload(providerContext) {
281
- return {
282
- messages: [
283
- { role: "system", content: providerContext.system },
284
- ...(providerContext.userMessages ?? []).map((message) => ({ role: "user", content: message.content })),
285
- ],
286
- };
287
- }
288
-
289
- async function notifyTurnEndBestEffort(turnNotifier, event) {
290
- if (!turnNotifier?.notifyTurnEnd) return { ok: false, reason: "not-configured", results: [] };
291
- try {
292
- return await turnNotifier.notifyTurnEnd(event);
293
- } catch (err) {
294
- // Notification must never change turn behavior.
295
- return { ok: false, reason: err?.message ?? String(err), results: [] };
296
- }
297
- }
@@ -26,6 +26,7 @@ export async function createRunnerRuntimeHost({
26
26
  webTools = [],
27
27
  permissionController = null,
28
28
  extensionPaths = [],
29
+ hostedTools = {},
29
30
  onRebind = null,
30
31
  createAgentSessionRuntimeImpl = createAgentSessionRuntime,
31
32
  createServices,
@@ -60,6 +61,7 @@ export async function createRunnerRuntimeHost({
60
61
  permissionController,
61
62
  authStorage,
62
63
  projectMarchDir,
64
+ hostedTools,
63
65
  });
64
66
  },
65
67
  });
@@ -0,0 +1,30 @@
1
+ import { createHeartbeat, formatError } from "../../debug/logger.mjs";
2
+
3
+ export function beginLoggedTurn({ logger, engine, modelId, provider, contextMode, userMessage, userRecallHints, startedAt = Date.now() } = {}) {
4
+ const turnId = `${startedAt.toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
5
+ let phase = "starting";
6
+ const turnLogger = logger?.child?.({ turnId, sessionName: engine?.sessionName, modelId, provider });
7
+ const heartbeat = createHeartbeat({
8
+ logger: turnLogger,
9
+ event: "turn.heartbeat",
10
+ getFields: () => ({ phase, elapsedMs: Date.now() - startedAt }),
11
+ });
12
+ turnLogger?.event("turn.start", {
13
+ userMessageLength: String(userMessage ?? "").length,
14
+ contextMode,
15
+ userRecallHintCount: userRecallHints?.length ?? 0,
16
+ });
17
+ return {
18
+ turnId,
19
+ logger: turnLogger,
20
+ setPhase(value) { phase = value; },
21
+ endSuccess(result) {
22
+ heartbeat.stop();
23
+ turnLogger?.event("turn.end", { status: "success", durationMs: Date.now() - startedAt, draftLength: result?.draft?.length ?? 0 });
24
+ },
25
+ endError(err) {
26
+ heartbeat.stop();
27
+ turnLogger?.error("turn.error", { durationMs: Date.now() - startedAt, phase, error: formatError(err) });
28
+ },
29
+ };
30
+ }
@@ -11,6 +11,8 @@ export async function runRunnerTurn({
11
11
  projectMarchDir,
12
12
  memoryStore,
13
13
  setModelCallKind,
14
+ logger = null,
15
+ setPhase = null,
14
16
  onMidTurnRecallHints,
15
17
  syncCurrentPiSidecar,
16
18
  autoNameSession,
@@ -24,8 +26,16 @@ export async function runRunnerTurn({
24
26
  const turnState = createTurnEventState();
25
27
  const midTurnRecallHints = [];
26
28
  ui.turnStart();
29
+ setPhase?.("subscribed");
30
+ logger?.event("turn.ui.start");
27
31
 
28
32
  const unsubscribe = activeSession.subscribe((event) => {
33
+ logSessionEvent(logger, event);
34
+ if (event.type === "tool_execution_start") setPhase?.(`tool_running:${event.toolName ?? "unknown"}`);
35
+ if (event.type === "tool_execution_end") setPhase?.("model_streaming");
36
+ if (event.type === "auto_retry_start") setPhase?.("retry_wait");
37
+ if (event.type === "auto_retry_end") setPhase?.("model_streaming");
38
+ if (event.type === "message_update") setPhase?.("model_streaming");
29
39
  handleRunnerSessionEvent(event, { ui, engine, state: turnState });
30
40
  if (event.type === "tool_execution_start") {
31
41
  const hints = flushAssistantRecall({ memoryStore, engine, turnState, currentProject });
@@ -42,7 +52,10 @@ export async function runRunnerTurn({
42
52
  text: userMessage ?? prompt,
43
53
  projectMarchDir,
44
54
  });
55
+ logger?.event("turn.attachments.resolved", { imageCount: attachmentReferences.images.length });
45
56
  setModelCallKind("user");
57
+ setPhase?.("model_request");
58
+ logger?.event("model.prompt.start", { contextMode });
46
59
  try {
47
60
  if (contextMode === "rebuild") resetPiMessageHistory(activeSession);
48
61
  await activeSession.prompt(
@@ -51,8 +64,10 @@ export async function runRunnerTurn({
51
64
  );
52
65
  } finally {
53
66
  setModelCallKind("model");
67
+ logger?.event("model.prompt.end");
54
68
  }
55
69
 
70
+ setPhase?.("finalizing");
56
71
  finalizeTurn({
57
72
  prompt,
58
73
  userMessage,
@@ -68,11 +83,36 @@ export async function runRunnerTurn({
68
83
  });
69
84
  return { draft: turnState.draft };
70
85
  } finally {
86
+ logger?.event("turn.ui.end");
71
87
  ui.turnEnd();
72
88
  unsubscribe();
73
89
  }
74
90
  }
75
91
 
92
+ function logSessionEvent(logger, event) {
93
+ if (!logger) return;
94
+ if (event.type === "message_update") {
95
+ const messageEvent = event.assistantMessageEvent;
96
+ logger.debug("session.event", {
97
+ type: event.type,
98
+ assistantMessageType: messageEvent?.type,
99
+ deltaLength: messageEvent?.delta?.length,
100
+ });
101
+ return;
102
+ }
103
+ logger.event("session.event", {
104
+ type: event.type,
105
+ toolName: event.toolName,
106
+ isError: event.isError,
107
+ attempt: event.attempt,
108
+ maxAttempts: event.maxAttempts,
109
+ delayMs: event.delayMs,
110
+ success: event.success,
111
+ errorMessage: event.errorMessage,
112
+ finalError: event.finalError,
113
+ });
114
+ }
115
+
76
116
  function finalizeTurn({ prompt, userMessage, userRecallHints, currentProject, memoryStore, engine, ui, turnState, midTurnRecallHints, syncCurrentPiSidecar, autoNameSession }) {
77
117
  closeAssistantReply({ ui, state: turnState });
78
118
  const assistantRecallHints = flushAssistantRecall({ memoryStore, engine, turnState, currentProject });
@@ -31,6 +31,7 @@ export function statusBarLine({
31
31
  mode = MODES.DO,
32
32
  contextTokens = null,
33
33
  activity = null,
34
+ lspStatus = null,
34
35
  }) {
35
36
  return formatStatusBarLine({
36
37
  engine: runner.engine,
@@ -43,6 +44,7 @@ export function statusBarLine({
43
44
  mode,
44
45
  contextTokens,
45
46
  activity,
47
+ lspStatus,
46
48
  });
47
49
  }
48
50
 
@@ -80,6 +82,7 @@ export function formatStatusBarLine({
80
82
  mode = MODES.DO,
81
83
  contextTokens = null,
82
84
  activity = null,
85
+ lspStatus = null,
83
86
  }) {
84
87
  const model = engine.modelId || "model?";
85
88
  const thinking = engine.thinkingLevel || "thinking?";
@@ -91,6 +94,8 @@ export function formatStatusBarLine({
91
94
  const modeSegment = `${mode === MODES.DISCUSS ? WARN : OK}${formatModeLabel(mode)}`;
92
95
  const runtime = `${C.cyan}${model}${DIM}·${thinking}`;
93
96
  const segments = [modeSegment, runtime];
97
+ const lspText = formatLspSegment(lspStatus);
98
+ if (lspText) segments.push(`${C.fg250}${lspText}`);
94
99
  const activityText = formatActivitySegment(activity);
95
100
  if (activityText) segments.push(`${C.fg250}${activityText}`);
96
101
  const compactTokens = formatCompactTokenCount(contextTokens);
@@ -100,6 +105,41 @@ export function formatStatusBarLine({
100
105
  return `${inner}${R}`;
101
106
  }
102
107
 
108
+ export function formatLspSegment(lspStatus) {
109
+ if (!lspStatus) return "";
110
+ const servers = lspStatus.servers ?? [];
111
+ const visible = servers.filter((server) => server.id);
112
+ if (visible.length === 0) return "lsp:off";
113
+ const parts = buildLspStatusParts(visible);
114
+ if (parts.length === 0) return "lsp:off";
115
+ return `lsp:${parts.join(",")}`;
116
+ }
117
+
118
+ function buildLspStatusParts(servers) {
119
+ const byId = new Map();
120
+ for (const server of servers) {
121
+ const id = shortLspId(server.id);
122
+ byId.set(id, mergeLspStatus(byId.get(id), server.status));
123
+ }
124
+ return [...byId.entries()]
125
+ .map(([id, status]) => `${id}${formatLspStatusMark(status)}`);
126
+ }
127
+
128
+ function mergeLspStatus(current, next) {
129
+ const rank = { failed: 5, installing: 4, starting: 3, busy: 2, ready: 1, idle: 1, unavailable: 0 };
130
+ if (!current) return next ?? "unavailable";
131
+ const currentRank = rank[current] ?? 0;
132
+ const nextRank = rank[next] ?? 0;
133
+ return nextRank > currentRank ? next : current;
134
+ }
135
+
136
+ function formatLspStatusMark(status) {
137
+ if (status === "failed") return "!";
138
+ if (status === "starting" || status === "installing") return "…";
139
+ if (status === "unavailable") return "?";
140
+ return "✓";
141
+ }
142
+
103
143
  function formatActivitySegment(activity) {
104
144
  if (!activity) return "";
105
145
  const label = String(activity.label ?? "").trim();
@@ -107,6 +147,11 @@ function formatActivitySegment(activity) {
107
147
  return [frame, label].filter(Boolean).join(" ");
108
148
  }
109
149
 
150
+ function shortLspId(id) {
151
+ if (id === "typescript") return "ts";
152
+ return String(id ?? "?");
153
+ }
154
+
110
155
  export function formatCompactTokenCount(tokens) {
111
156
  const value = Number(tokens);
112
157
  if (!Number.isFinite(value) || value <= 0) return "";
@@ -40,7 +40,7 @@ const DEFAULT_CATEGORIES = {
40
40
  terminal_search: PERM.COMMAND_EXEC,
41
41
  terminal_read: PERM.COMMAND_EXEC,
42
42
  terminal_snapshot: PERM.COMMAND_EXEC,
43
- web_search: PERM.NETWORK_EXTERNAL,
43
+ external_web_search: PERM.NETWORK_EXTERNAL,
44
44
  web_fetch: PERM.NETWORK_EXTERNAL,
45
45
  };
46
46
 
@@ -0,0 +1,23 @@
1
+ export async function closeMarchRuntime({ runner, memoryStore, ui, logger = null, blankLine = false }) {
2
+ let firstError = null;
3
+ try {
4
+ await runner.dispose();
5
+ } catch (err) {
6
+ firstError ??= err;
7
+ }
8
+ try {
9
+ memoryStore.close();
10
+ } catch (err) {
11
+ firstError ??= err;
12
+ }
13
+ try {
14
+ if (blankLine) ui.writeln("");
15
+ await ui.close();
16
+ } catch (err) {
17
+ firstError ??= err;
18
+ }
19
+ if (firstError) {
20
+ logger?.error("process.close.error", { error: firstError });
21
+ throw firstError;
22
+ }
23
+ }
@@ -27,6 +27,7 @@ export function createStatusLineUpdater({
27
27
  mode: getMode(),
28
28
  contextTokens,
29
29
  activity: formatActivity(activity, frameIndex),
30
+ lspStatus: runner.getLspStatus?.() ?? null,
30
31
  });
31
32
  ui.setStatusBar(line);
32
33
  return line;
@@ -55,7 +55,7 @@ export function formatToolStartLine(name, args = {}) {
55
55
  if (name === "command_exec") return joinToolParts("◆", name, [compactText(args?.command ?? "")]);
56
56
  if (name === "terminal_send") return joinToolParts("◆", name, [args?.shell_id, formatTerminalSendAction(args)]);
57
57
  if (name?.startsWith?.("terminal_")) return joinToolParts("◆", name, [args?.shell_id, formatTerminalDetails(args)]);
58
- if (name === "web_search") return joinToolParts("◆", name, [quoteCompact(args?.query ?? "")]);
58
+ if (name === "external_web_search") return joinToolParts("◆", name, [quoteCompact(args?.query ?? "")]);
59
59
  if (name === "web_fetch") return joinToolParts("◆", name, [compactText(args?.url ?? "")]);
60
60
  if (name === "context_stats") return joinToolParts("◆", name, []);
61
61
  if (name === "read") {