march-cli 0.1.38 → 0.1.39

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.
@@ -1,4 +1,4 @@
1
- const BACKGROUND_METHODS_TO_BUFFER = new Set([
1
+ const RENDER_METHODS = new Set([
2
2
  "turnStart",
3
3
  "turnEnd",
4
4
  "assistantReplyEnd",
@@ -6,28 +6,35 @@ const BACKGROUND_METHODS_TO_BUFFER = new Set([
6
6
  "thinkingStart",
7
7
  "thinkingDelta",
8
8
  "thinkingEnd",
9
+ "thinkingBlock",
9
10
  "toolStart",
10
11
  "toolEnd",
11
12
  "retryStart",
12
13
  "retryEnd",
13
14
  "status",
14
- "debugLines",
15
15
  "recall",
16
- "providerQuotaSnapshot",
17
16
  "editDiff",
17
+ "write",
18
18
  "writeln",
19
+ "clearOutput",
19
20
  ]);
20
21
 
21
- export function createWorkspaceOutputRouter({ ui, activeProjectId, activeSessionId = null }) {
22
+ const MAX_RENDER_EVENTS_PER_ROUTE = 4000;
23
+
24
+ export function createWorkspaceOutputRouter({ ui, activeProjectId, activeSessionId = null, onRenderTimelineChange = null }) {
22
25
  let active = routeKey(activeProjectId, activeSessionId);
23
- const buffers = new Map();
26
+ const timelines = new Map();
24
27
 
25
28
  return {
26
29
  setActiveProject(projectId) {
27
- active = routeKey(projectId, null);
30
+ this.setActiveSession(projectId, null);
28
31
  },
29
- setActiveSession(projectId, sessionId) {
30
- active = routeKey(projectId, sessionId);
32
+ setActiveSession(projectId, sessionId, { renderTimeline = null } = {}) {
33
+ const next = routeKey(projectId, sessionId);
34
+ if (Array.isArray(renderTimeline)) setRenderEvents(next, renderTimeline);
35
+ if (next === active) return renderRoute(next);
36
+ active = next;
37
+ return renderRoute(next);
31
38
  },
32
39
  getActiveRouteKey() {
33
40
  return active;
@@ -46,8 +53,9 @@ export function createWorkspaceOutputRouter({ ui, activeProjectId, activeSession
46
53
  if (typeof value !== "function") return value;
47
54
  return (...args) => {
48
55
  const key = routeKey(projectId, typeof getSessionId === "function" ? getSessionId() : sessionId);
49
- if (isActiveRoute(key) || !BACKGROUND_METHODS_TO_BUFFER.has(prop)) return value.apply(ui, args);
50
- bufferBackgroundCall(key, prop, args);
56
+ if (!RENDER_METHODS.has(prop)) return value.apply(ui, args);
57
+ recordRenderEvent(key, prop, args);
58
+ if (key === active) return value.apply(ui, args);
51
59
  return undefined;
52
60
  };
53
61
  },
@@ -60,41 +68,47 @@ export function createWorkspaceOutputRouter({ ui, activeProjectId, activeSession
60
68
  },
61
69
  });
62
70
  },
63
- getBufferedCalls(projectId, sessionId = null) {
64
- return [...(buffers.get(routeKey(projectId, sessionId)) ?? [])];
71
+ renderActiveSession() {
72
+ return renderRoute(active);
65
73
  },
66
- getBufferedCallCount(projectId, sessionId = null) {
67
- return buffers.get(routeKey(projectId, sessionId))?.length ?? 0;
74
+ getRenderEvents(projectId, sessionId = null) {
75
+ return [...(timelines.get(routeKey(projectId, sessionId)) ?? [])];
68
76
  },
69
- replayBufferedCalls(projectId, sessionId = null) {
70
- const key = routeKey(projectId, sessionId);
71
- const calls = buffers.get(key) ?? [];
72
- buffers.delete(key);
73
- for (const call of calls) replayBufferedCall(call);
74
- return calls.length;
77
+ setRenderEvents(projectId, sessionId = null, events = []) {
78
+ setRenderEvents(routeKey(projectId, sessionId), events);
75
79
  },
76
- clearBufferedCalls(projectId, sessionId = null) {
77
- buffers.delete(routeKey(projectId, sessionId));
80
+ getRenderEventCount(projectId, sessionId = null) {
81
+ return timelines.get(routeKey(projectId, sessionId))?.length ?? 0;
78
82
  },
79
83
  };
80
84
 
81
- function isActiveRoute(key) {
82
- if (key === active) return true;
83
- const current = parseRouteKey(active);
84
- const candidate = parseRouteKey(key);
85
- return current.sessionId == null && current.projectId === candidate.projectId;
85
+ function renderRoute(key) {
86
+ ui.clearOutput?.();
87
+ const events = timelines.get(key) ?? [];
88
+ for (const event of events) applyRenderEvent(event);
89
+ return events.length;
86
90
  }
87
91
 
88
- function replayBufferedCall({ method, args }) {
92
+ function applyRenderEvent({ method, args }) {
89
93
  const value = ui[method];
90
94
  if (typeof value === "function") value.apply(ui, args);
91
95
  }
92
96
 
93
- function bufferBackgroundCall(key, method, args) {
94
- const calls = buffers.get(key) ?? [];
95
- calls.push({ method, args, at: Date.now() });
96
- if (calls.length > 2000) calls.splice(0, calls.length - 2000);
97
- buffers.set(key, calls);
97
+ function recordRenderEvent(key, method, args) {
98
+ if (method === "clearOutput") {
99
+ timelines.delete(key);
100
+ onRenderTimelineChange?.({ ...parseRouteKey(key), events: [], event: { method, args } });
101
+ return;
102
+ }
103
+ const events = timelines.get(key) ?? [];
104
+ events.push({ method, args, at: Date.now() });
105
+ if (events.length > MAX_RENDER_EVENTS_PER_ROUTE) events.splice(0, events.length - MAX_RENDER_EVENTS_PER_ROUTE);
106
+ timelines.set(key, events);
107
+ onRenderTimelineChange?.({ ...parseRouteKey(key), events: [...events], event: { method, args } });
108
+ }
109
+
110
+ function setRenderEvents(key, events) {
111
+ timelines.set(key, normalizeRenderEvents(events));
98
112
  }
99
113
  }
100
114
 
@@ -106,3 +120,8 @@ function parseRouteKey(key) {
106
120
  const [projectId, sessionId = ""] = String(key ?? "").split(":", 2);
107
121
  return { projectId: projectId || null, sessionId: sessionId || null };
108
122
  }
123
+
124
+ function normalizeRenderEvents(events) {
125
+ if (!Array.isArray(events)) return [];
126
+ return events.filter((event) => typeof event?.method === "string" && Array.isArray(event.args));
127
+ }
@@ -8,6 +8,7 @@ import { loadProjectLifecycleHookManifests } from "../../extensions/lifecycle-ma
8
8
  import { loadKeybindings } from "../input/keybindings.mjs";
9
9
  import { loadPromptTemplates } from "../input/prompt-templates.mjs";
10
10
  import { loadOrCreateProjectId } from "../../workspace/project-id.mjs";
11
+ import { syncRuntimeSessionStateFromRunner } from "./runtime-session-state.mjs";
11
12
 
12
13
  export async function createWorkspaceProjectRuntime({
13
14
  project,
@@ -73,6 +74,7 @@ export async function createWorkspaceProjectRuntime({
73
74
  refreshStatusBar,
74
75
  onNotificationActivation,
75
76
  });
77
+ syncRuntimeSessionStateFromRunner(sessionState, runner, sessionsRoot);
76
78
 
77
79
  return {
78
80
  project,
@@ -0,0 +1,9 @@
1
+ import { join } from "node:path";
2
+
3
+ export function syncRuntimeSessionStateFromRunner(sessionState, runner, sessionsRoot) {
4
+ const sessionId = runner?.getSessionStats?.()?.sessionId ?? null;
5
+ if (!sessionState || !sessionId) return null;
6
+ sessionState.sessionId = sessionId;
7
+ sessionState.sessionDir = sessionsRoot ? join(sessionsRoot, sessionId) : sessionState.sessionDir;
8
+ return sessionState;
9
+ }
@@ -7,7 +7,7 @@ export const MARCH_LIFECYCLE_LAYERS = Object.freeze([
7
7
  {
8
8
  name: "march-agent-runtime",
9
9
  owner: "March runner",
10
- effects: Object.freeze(["read-session-ref", "read-sidecar-metadata", "read-runtime-state"]),
10
+ effects: Object.freeze(["read-session-ref", "read-session-state", "read-runtime-state"]),
11
11
  },
12
12
  {
13
13
  name: "march-collaboration",
@@ -24,7 +24,7 @@ export const DEFAULT_MARCH_HOOK_POLICY = Object.freeze({
24
24
  "read-agent-ref",
25
25
  "read-workspace-ref",
26
26
  "read-session-ref",
27
- "read-sidecar-metadata",
27
+ "read-session-state",
28
28
  "read-diff-metadata",
29
29
  "read-runtime-diagnostics",
30
30
  "write-diagnostics",
package/src/main.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { createRequire } from "node:module";
1
2
  import { homedir } from "node:os";
2
3
  import { join, relative } from "node:path";
3
4
  import { fileURLToPath } from "node:url";
@@ -13,6 +14,8 @@ import { installNetworkEnvironment } from "./network/environment.mjs";
13
14
  import { runEarlyCliCommand } from "./cli/startup/early-command.mjs";
14
15
  import { maybeRunGatewayDaemonCommand } from "./cli/startup/gateway-daemon-command.mjs";
15
16
 
17
+ const { version: packageVersion } = createRequire(import.meta.url)("../package.json");
18
+
16
19
  export async function run(argv) {
17
20
  const cwd = process.cwd();
18
21
  loadDotEnv(cwd);
@@ -23,6 +26,10 @@ export async function run(argv) {
23
26
  showHelp();
24
27
  return 0;
25
28
  }
29
+ if (args.version) {
30
+ process.stdout.write(`${packageVersion}\n`);
31
+ return 0;
32
+ }
26
33
 
27
34
  const config = loadConfig(cwd);
28
35
  const stateRoot = join(homedir(), ".march");
@@ -67,7 +74,6 @@ export async function run(argv) {
67
74
  }
68
75
 
69
76
  const dumpContextPath = args.dumpContext ? relative(cwd, app.contextDumpRoot) : null;
70
- if (app.startupResume.transcriptTurns?.length > 0) app.ui.restoreTranscript?.(app.startupResume.transcriptTurns);
71
77
  for (const line of formatStartupBanner({ cwd, modelId: app.runner.engine.modelId, thinkingLevel: app.runner.engine.thinkingLevel, mode: app.modeState.get(), dumpContextPath })) app.ui.writeln(line);
72
78
  try {
73
79
  await runInteractiveRepl({
@@ -1,19 +1,5 @@
1
- import { savePiSessionSidecar } from "./sidecar.mjs";
1
+ import { syncMarchSessionState } from "./state/march-session-sync.mjs";
2
2
 
3
- export function syncPiSessionSidecar({ enabled = false, projectMarchDir, engine, sessionStats, metadata = {} }) {
4
- if (!enabled || !projectMarchDir || !sessionStats?.persisted || !sessionStats.sessionFile) {
5
- return null;
6
- }
7
-
8
- return savePiSessionSidecar({
9
- projectMarchDir,
10
- sessionRef: sessionStats.sessionFile,
11
- engine,
12
- metadata: {
13
- sessionId: sessionStats.sessionId,
14
- sessionFile: sessionStats.sessionFile,
15
- runtimeHost: Boolean(sessionStats.runtimeHost),
16
- ...metadata,
17
- },
18
- });
3
+ export function syncPiSessionSidecar(options) {
4
+ return syncMarchSessionState(options);
19
5
  }
@@ -1,69 +1,68 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
- import { basename, join } from "node:path";
1
+ import {
2
+ captureMarchSessionState,
3
+ getLegacyPiSidecarDir,
4
+ getLegacyPiSidecarPath,
5
+ loadMarchSessionStateForPiBackend,
6
+ loadLegacyPiSidecar,
7
+ saveMarchSessionStateValue,
8
+ } from "./state/march-session-state.mjs";
3
9
 
4
10
  export const PI_SIDECAR_VERSION = 1;
5
11
 
6
12
  export function getPiSidecarDir(projectMarchDir) {
7
- return join(projectMarchDir, "pi-sidecars");
13
+ return getLegacyPiSidecarDir(projectMarchDir);
8
14
  }
9
15
 
10
16
  export function getPiSidecarPath(projectMarchDir, sessionRef) {
11
- return join(getPiSidecarDir(projectMarchDir), `${normalizeSessionRef(sessionRef)}.json`);
17
+ return getLegacyPiSidecarPath(projectMarchDir, sessionRef);
12
18
  }
13
19
 
14
20
  export function captureContextSidecar(engine, metadata = {}) {
15
- return {
16
- version: PI_SIDECAR_VERSION,
17
- savedAt: new Date().toISOString(),
18
- ...metadata,
19
- cwd: engine.cwd,
20
- modelId: engine.modelId,
21
- provider: engine.provider,
22
- sessionName: engine.sessionName ?? "",
23
- thinkingLevel: engine.thinkingLevel,
24
- namespace: engine.namespace,
25
- pendingAssistantRecallHints: engine.pendingAssistantRecallHints ?? [],
26
- turns: engine.turns,
27
- };
21
+ return captureMarchSessionState(engine, {
22
+ sessionId: metadata.sessionId,
23
+ backend: {
24
+ type: "pi",
25
+ sessionId: metadata.sessionId ?? null,
26
+ sessionFile: metadata.sessionFile ?? null,
27
+ runtimeHost: Boolean(metadata.runtimeHost),
28
+ },
29
+ metadata,
30
+ });
28
31
  }
29
32
 
30
33
  export function savePiSessionSidecar({ projectMarchDir, sessionRef, engine, metadata = {} }) {
31
34
  return savePiSessionSidecarState({
32
35
  projectMarchDir,
33
36
  sessionRef,
34
- state: captureContextSidecar(engine, metadata),
37
+ state: captureContextSidecar(engine, { sessionFile: sessionRef, ...metadata }),
35
38
  });
36
39
  }
37
40
 
38
41
  export function savePiSessionSidecarState({ projectMarchDir, sessionRef, state }) {
39
- const sidecarDir = getPiSidecarDir(projectMarchDir);
40
- mkdirSync(sidecarDir, { recursive: true });
41
- validateSidecarState(state);
42
- const path = getPiSidecarPath(projectMarchDir, sessionRef);
43
- writeFileSync(path, JSON.stringify(state, null, 2), "utf8");
44
- return { path, state };
42
+ return saveMarchSessionStateValue({
43
+ projectMarchDir,
44
+ sessionId: state.sessionId ?? state.backend?.sessionId ?? state.sessionFile ?? sessionRef,
45
+ state: normalizeLegacyState(state, sessionRef),
46
+ });
45
47
  }
46
48
 
47
49
  export function loadPiSessionSidecar({ projectMarchDir, sessionRef }) {
48
- const path = getPiSidecarPath(projectMarchDir, sessionRef);
49
- if (!existsSync(path)) return null;
50
- const state = JSON.parse(readFileSync(path, "utf8"));
51
- if (!isValidSidecarState(state)) {
52
- throw new Error("Invalid pi session sidecar");
53
- }
54
- return { path, state };
50
+ return loadMarchSessionStateForPiBackend({ projectMarchDir, sessionId: null, sessionRef }) ?? loadLegacyPiSidecar({ projectMarchDir, sessionRef });
55
51
  }
56
52
 
57
- function validateSidecarState(state) {
58
- if (!isValidSidecarState(state)) throw new Error("Invalid pi session sidecar");
53
+ export function loadPiSessionContextState({ projectMarchDir, sessionRef, sessionId = null }) {
54
+ return loadMarchSessionStateForPiBackend({ projectMarchDir, sessionId, sessionRef });
59
55
  }
60
56
 
61
- function isValidSidecarState(state) {
62
- return state?.version === PI_SIDECAR_VERSION && Boolean(state.cwd) && Array.isArray(state.turns);
63
- }
64
-
65
- function normalizeSessionRef(sessionRef) {
66
- const ref = basename(String(sessionRef).trim()).replace(/\.jsonl$/i, "");
67
- if (!ref || ref === "." || ref === "..") throw new Error("Invalid pi session reference");
68
- return ref.replace(/[^a-zA-Z0-9._-]/g, "_");
57
+ function normalizeLegacyState(state, sessionRef) {
58
+ return {
59
+ ...state,
60
+ sessionId: state.sessionId ?? state.backend?.sessionId ?? state.sessionFile ?? sessionRef,
61
+ backend: state.backend ?? {
62
+ type: "pi",
63
+ sessionId: state.sessionId ?? null,
64
+ sessionFile: state.sessionFile ?? sessionRef,
65
+ runtimeHost: Boolean(state.runtimeHost),
66
+ },
67
+ };
69
68
  }
@@ -0,0 +1,175 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ import { loadPiSessionTranscriptTurns } from "../transcript.mjs";
4
+
5
+ export const MARCH_SESSION_STATE_VERSION = 1;
6
+
7
+ export function getMarchSessionStateRoot(projectMarchDir) {
8
+ return join(projectMarchDir, "sessions");
9
+ }
10
+
11
+ export function getMarchSessionStateDir(projectMarchDir, sessionId) {
12
+ return join(getMarchSessionStateRoot(projectMarchDir), normalizeSessionId(sessionId));
13
+ }
14
+
15
+ export function getMarchSessionStatePath(projectMarchDir, sessionId) {
16
+ return join(getMarchSessionStateDir(projectMarchDir, sessionId), "state.json");
17
+ }
18
+
19
+ export function captureMarchSessionState(engine, { sessionId, backend = null, metadata = {} } = {}) {
20
+ return {
21
+ version: MARCH_SESSION_STATE_VERSION,
22
+ savedAt: new Date().toISOString(),
23
+ sessionId: sessionId ?? metadata.sessionId ?? backend?.sessionId ?? null,
24
+ backend,
25
+ ...metadata,
26
+ cwd: engine.cwd,
27
+ modelId: engine.modelId,
28
+ provider: engine.provider,
29
+ sessionName: engine.sessionName ?? "",
30
+ thinkingLevel: engine.thinkingLevel,
31
+ namespace: engine.namespace,
32
+ pendingAssistantRecallHints: engine.pendingAssistantRecallHints ?? [],
33
+ turns: engine.turns,
34
+ };
35
+ }
36
+
37
+ export function saveMarchSessionState({ projectMarchDir, sessionId, engine, backend = null, metadata = {} }) {
38
+ return saveMarchSessionStateValue({
39
+ projectMarchDir,
40
+ sessionId,
41
+ state: captureMarchSessionState(engine, { sessionId, backend, metadata }),
42
+ });
43
+ }
44
+
45
+ export function saveMarchSessionStateValue({ projectMarchDir, sessionId, state }) {
46
+ if (!sessionId) throw new Error("March session id is required");
47
+ const existing = loadMarchSessionState({ projectMarchDir, sessionId })?.state ?? null;
48
+ const nextState = normalizeMarchSessionStateForSave({ ...existing, ...state, renderTimeline: state.renderTimeline ?? existing?.renderTimeline });
49
+ validateMarchSessionState(nextState);
50
+ const dir = getMarchSessionStateDir(projectMarchDir, sessionId);
51
+ mkdirSync(dir, { recursive: true });
52
+ const path = getMarchSessionStatePath(projectMarchDir, sessionId);
53
+ writeFileSync(path, JSON.stringify({ ...nextState, sessionId: nextState.sessionId ?? sessionId }, null, 2), "utf8");
54
+ return { path, state: { ...nextState, sessionId: nextState.sessionId ?? sessionId } };
55
+ }
56
+
57
+ export function loadMarchSessionState({ projectMarchDir, sessionId }) {
58
+ const path = getMarchSessionStatePath(projectMarchDir, sessionId);
59
+ if (!existsSync(path)) return null;
60
+ const state = normalizeMarchSessionStateForSave(JSON.parse(readFileSync(path, "utf8")));
61
+ if (!isValidMarchSessionState(state)) throw new Error("Invalid March session state");
62
+ return { path, state };
63
+ }
64
+
65
+ export function loadMarchSessionContextState({ projectMarchDir, sessionId, backendSessionFile = null }) {
66
+ const stored = loadMarchSessionState({ projectMarchDir, sessionId });
67
+ if (!stored) return null;
68
+ const sessionFile = backendSessionFile ?? stored.state.backend?.sessionFile ?? stored.state.sessionFile ?? null;
69
+ return { ...stored, state: withBackendTranscriptTurns(stored.state, sessionFile) };
70
+ }
71
+
72
+ export function listMarchSessionStates({ projectMarchDir }) {
73
+ const root = getMarchSessionStateRoot(projectMarchDir);
74
+ if (!existsSync(root)) return [];
75
+ return readdirSync(root, { withFileTypes: true })
76
+ .filter((entry) => entry.isDirectory())
77
+ .map((entry) => {
78
+ try {
79
+ return loadMarchSessionState({ projectMarchDir, sessionId: entry.name });
80
+ } catch {
81
+ return null;
82
+ }
83
+ })
84
+ .filter(Boolean);
85
+ }
86
+
87
+ export function loadMarchSessionStateForPiBackend({ projectMarchDir, sessionId, sessionRef }) {
88
+ const marchState = sessionId ? loadMarchSessionContextState({ projectMarchDir, sessionId, backendSessionFile: sessionRef }) : null;
89
+ if (marchState) return marchState;
90
+ const matchingState = findMarchSessionStateByPiBackend({ projectMarchDir, sessionRef });
91
+ if (matchingState) return { ...matchingState, state: withBackendTranscriptTurns(matchingState.state, sessionRef) };
92
+ return loadLegacyPiSidecarContextState({ projectMarchDir, sessionRef });
93
+ }
94
+
95
+ function findMarchSessionStateByPiBackend({ projectMarchDir, sessionRef }) {
96
+ const normalizedRef = normalizeSessionRef(sessionRef);
97
+ return listMarchSessionStates({ projectMarchDir }).find(({ state }) => {
98
+ const sessionFile = state.backend?.sessionFile ?? state.sessionFile ?? null;
99
+ return sessionFile && normalizeSessionRef(sessionFile) === normalizedRef;
100
+ }) ?? null;
101
+ }
102
+
103
+ export function getLegacyPiSidecarDir(projectMarchDir) {
104
+ return join(projectMarchDir, "pi-sidecars");
105
+ }
106
+
107
+ export function getLegacyPiSidecarPath(projectMarchDir, sessionRef) {
108
+ return join(getLegacyPiSidecarDir(projectMarchDir), `${normalizeSessionRef(sessionRef)}.json`);
109
+ }
110
+
111
+ export function loadLegacyPiSidecar({ projectMarchDir, sessionRef }) {
112
+ const path = getLegacyPiSidecarPath(projectMarchDir, sessionRef);
113
+ if (!existsSync(path)) return null;
114
+ const state = normalizeMarchSessionStateForSave(JSON.parse(readFileSync(path, "utf8")));
115
+ if (!isValidMarchSessionState(state)) throw new Error("Invalid March session state");
116
+ return { path, state };
117
+ }
118
+
119
+ export function loadLegacyPiSidecarContextState({ projectMarchDir, sessionRef }) {
120
+ const legacy = loadLegacyPiSidecar({ projectMarchDir, sessionRef });
121
+ if (!legacy) return null;
122
+ return { ...legacy, state: withBackendTranscriptTurns(legacy.state, sessionRef) };
123
+ }
124
+
125
+ export function normalizeSessionId(sessionId) {
126
+ const value = String(sessionId ?? "").trim();
127
+ if (!value || value === "." || value === "..") throw new Error("Invalid March session id");
128
+ return value.replace(/[^a-zA-Z0-9._-]/g, "_");
129
+ }
130
+
131
+ function validateMarchSessionState(state) {
132
+ if (!isValidMarchSessionState(state)) throw new Error("Invalid March session state");
133
+ }
134
+
135
+ function isValidMarchSessionState(state) {
136
+ return state?.version === MARCH_SESSION_STATE_VERSION
137
+ && Boolean(state.cwd)
138
+ && Array.isArray(state.turns)
139
+ && Array.isArray(state.renderTimeline);
140
+ }
141
+
142
+ function normalizeMarchSessionStateForSave(state) {
143
+ return {
144
+ ...state,
145
+ renderTimeline: normalizePersistedRenderTimeline(state.renderTimeline),
146
+ };
147
+ }
148
+
149
+ function normalizePersistedRenderTimeline(events) {
150
+ if (!Array.isArray(events)) return [];
151
+ return events
152
+ .filter((event) => typeof event?.method === "string" && Array.isArray(event.args))
153
+ .map((event) => ({ method: event.method, args: event.args, at: event.at ?? null }));
154
+ }
155
+
156
+ function normalizeSessionRef(sessionRef) {
157
+ const ref = basename(String(sessionRef).trim()).replace(/\.jsonl$/i, "");
158
+ if (!ref || ref === "." || ref === "..") throw new Error("Invalid pi session reference");
159
+ return ref.replace(/[^a-zA-Z0-9._-]/g, "_");
160
+ }
161
+
162
+ function withBackendTranscriptTurns(state, sessionFile) {
163
+ if (!sessionFile) return { ...state };
164
+ let transcriptTurns = [];
165
+ try {
166
+ transcriptTurns = loadPiSessionTranscriptTurns(sessionFile);
167
+ } catch {
168
+ return { ...state };
169
+ }
170
+ if (transcriptTurns.length <= (state.turns?.length ?? 0)) return { ...state };
171
+ return {
172
+ ...state,
173
+ turns: transcriptTurns,
174
+ };
175
+ }
@@ -0,0 +1,20 @@
1
+ import { saveMarchSessionState } from "./march-session-state.mjs";
2
+
3
+ export function syncMarchSessionState({ enabled = false, projectMarchDir, engine, sessionStats, metadata = {} }) {
4
+ if (!enabled || !projectMarchDir || !sessionStats?.persisted || !sessionStats.sessionId) {
5
+ return null;
6
+ }
7
+
8
+ return saveMarchSessionState({
9
+ projectMarchDir,
10
+ sessionId: sessionStats.sessionId,
11
+ engine,
12
+ backend: {
13
+ type: "pi",
14
+ sessionId: sessionStats.sessionId,
15
+ sessionFile: sessionStats.sessionFile ?? null,
16
+ runtimeHost: Boolean(sessionStats.runtimeHost),
17
+ },
18
+ metadata,
19
+ });
20
+ }
@@ -0,0 +1,60 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { getMarchSessionStatePath, loadMarchSessionState, saveMarchSessionStateValue } from "./march-session-state.mjs";
3
+
4
+ export function loadMarchSessionRenderTimeline({ projectMarchDir, sessionId }) {
5
+ const persistedRender = readPersistedRenderTimelineInfo({ projectMarchDir, sessionId });
6
+ const stored = loadMarchSessionState({ projectMarchDir, sessionId });
7
+ if (!stored) return null;
8
+ const renderTimeline = normalizeSessionRenderTimeline(stored.state.renderTimeline);
9
+ return {
10
+ path: stored.path,
11
+ renderTimeline: persistedRender.hasUiOwnedTimeline ? renderTimeline : renderTimelineFromTurns(stored.state.turns ?? []),
12
+ };
13
+ }
14
+
15
+ export function saveMarchSessionRenderTimeline({ projectMarchDir, sessionId, renderTimeline }) {
16
+ const stored = loadMarchSessionState({ projectMarchDir, sessionId });
17
+ if (!stored) return null;
18
+ return saveMarchSessionStateValue({
19
+ projectMarchDir,
20
+ sessionId,
21
+ state: {
22
+ ...stored.state,
23
+ renderTimeline: normalizeSessionRenderTimeline(renderTimeline),
24
+ renderTimelineUpdatedAt: new Date().toISOString(),
25
+ },
26
+ });
27
+ }
28
+
29
+ export function normalizeSessionRenderTimeline(events) {
30
+ if (!Array.isArray(events)) return [];
31
+ return events
32
+ .filter((event) => typeof event?.method === "string" && Array.isArray(event.args))
33
+ .map((event) => ({ method: event.method, args: event.args, at: event.at ?? null }));
34
+ }
35
+
36
+ function readPersistedRenderTimelineInfo({ projectMarchDir, sessionId }) {
37
+ try {
38
+ const path = getMarchSessionStatePath(projectMarchDir, sessionId);
39
+ if (!existsSync(path)) return { hasUiOwnedTimeline: false };
40
+ const raw = JSON.parse(readFileSync(path, "utf8"));
41
+ const rawTimeline = normalizeSessionRenderTimeline(raw.renderTimeline);
42
+ return { hasUiOwnedTimeline: Boolean(raw.renderTimelineUpdatedAt) || rawTimeline.length > 0 };
43
+ } catch {
44
+ return { hasUiOwnedTimeline: false };
45
+ }
46
+ }
47
+
48
+ function renderTimelineFromTurns(turns) {
49
+ return turns.flatMap((turn) => {
50
+ const events = [];
51
+ if (turn.userMessage) events.push({ method: "writeln", args: [turn.userMessage], at: null });
52
+ if (turn.assistantMessage) {
53
+ events.push({ method: "turnStart", args: [], at: null });
54
+ events.push({ method: "textDelta", args: [turn.assistantMessage], at: null });
55
+ events.push({ method: "assistantReplyEnd", args: [], at: null });
56
+ events.push({ method: "turnEnd", args: [], at: null });
57
+ }
58
+ return events;
59
+ });
60
+ }
@@ -1,5 +1,6 @@
1
1
  import { resolve } from "node:path";
2
2
  import { listPiSessionInfos } from "../session/pi-manager.mjs";
3
+ import { listMarchSessionStates } from "../session/state/march-session-state.mjs";
3
4
  import { listRegisteredProjects } from "./project-registry.mjs";
4
5
 
5
6
  export async function listWorkspaceSessions({ stateRoot, currentProjectId = null, listSessions = listPiSessionInfos }) {
@@ -10,6 +11,7 @@ export async function listWorkspaceSessions({ stateRoot, currentProjectId = null
10
11
  let sessions = [];
11
12
  try {
12
13
  sessions = await listSessions({ cwd: project.rootPath, projectMarchDir });
14
+ sessions = mergeMarchSessionStates({ projectMarchDir, backendSessions: sessions });
13
15
  } catch {
14
16
  sessions = [];
15
17
  }
@@ -73,3 +75,28 @@ function formatWorkspaceSessionTime(value) {
73
75
  if (!value) return "no saved time";
74
76
  return String(value).slice(0, 16).replace("T", " ");
75
77
  }
78
+
79
+ function mergeMarchSessionStates({ projectMarchDir, backendSessions }) {
80
+ const backendById = new Map(backendSessions.map((session) => [session.id, session]));
81
+ const backendByPath = new Map(backendSessions.map((session) => [session.path, session]));
82
+ const marchSessions = listMarchSessionStates({ projectMarchDir }).map(({ state }) => {
83
+ const backend = state.backend?.type === "pi"
84
+ ? backendById.get(state.backend.sessionId) ?? backendByPath.get(state.backend.sessionFile)
85
+ : null;
86
+ return {
87
+ id: state.sessionId,
88
+ path: state.backend?.sessionFile ?? backend?.path ?? null,
89
+ savedAt: state.savedAt,
90
+ createdAt: backend?.createdAt ?? "",
91
+ cwd: state.cwd,
92
+ name: state.sessionName || backend?.name || "",
93
+ turnCount: state.turns?.length ?? backend?.turnCount ?? 0,
94
+ firstMessage: state.turns?.[0]?.userMessage ?? backend?.firstMessage ?? "",
95
+ parentSessionPath: backend?.parentSessionPath ?? null,
96
+ backend,
97
+ };
98
+ });
99
+ const seenBackendIds = new Set(marchSessions.map((session) => session.backend?.id).filter(Boolean));
100
+ const legacyBackendSessions = backendSessions.filter((session) => !seenBackendIds.has(session.id));
101
+ return [...marchSessions, ...legacyBackendSessions];
102
+ }