march-cli 0.1.39 → 0.1.41

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,29 +1,45 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { getMarchSessionStatePath, loadMarchSessionState, saveMarchSessionStateValue } from "./march-session-state.mjs";
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { getMarchSessionStatePath, loadMarchSessionState, normalizeSessionId } from "./march-session-state.mjs";
4
+
5
+ export const TUI_SESSION_TIMELINE_VERSION = 1;
6
+
7
+ export function getTuiSessionUiStateRoot(projectMarchDir) {
8
+ return join(projectMarchDir, "ui", "tui", "sessions");
9
+ }
10
+
11
+ export function getTuiSessionTimelinePath(projectMarchDir, sessionId) {
12
+ return join(getTuiSessionUiStateRoot(projectMarchDir), normalizeSessionId(sessionId), "timeline.json");
13
+ }
3
14
 
4
15
  export function loadMarchSessionRenderTimeline({ projectMarchDir, sessionId }) {
5
- const persistedRender = readPersistedRenderTimelineInfo({ projectMarchDir, sessionId });
16
+ const tuiTimeline = loadTuiRenderTimeline({ projectMarchDir, sessionId });
17
+ if (tuiTimeline) return tuiTimeline;
18
+
19
+ const legacyTimeline = loadLegacyCoreRenderTimeline({ projectMarchDir, sessionId });
20
+ if (legacyTimeline) return legacyTimeline;
21
+
6
22
  const stored = loadMarchSessionState({ projectMarchDir, sessionId });
7
23
  if (!stored) return null;
8
- const renderTimeline = normalizeSessionRenderTimeline(stored.state.renderTimeline);
9
24
  return {
10
25
  path: stored.path,
11
- renderTimeline: persistedRender.hasUiOwnedTimeline ? renderTimeline : renderTimelineFromTurns(stored.state.turns ?? []),
26
+ renderTimeline: renderTimelineFromTurns(stored.state.turns ?? []),
27
+ source: "core-turns",
12
28
  };
13
29
  }
14
30
 
15
31
  export function saveMarchSessionRenderTimeline({ projectMarchDir, sessionId, renderTimeline }) {
16
- const stored = loadMarchSessionState({ projectMarchDir, sessionId });
17
- if (!stored) return null;
18
- return saveMarchSessionStateValue({
19
- projectMarchDir,
32
+ if (!sessionId) throw new Error("March session id is required");
33
+ const state = {
34
+ version: TUI_SESSION_TIMELINE_VERSION,
35
+ savedAt: new Date().toISOString(),
20
36
  sessionId,
21
- state: {
22
- ...stored.state,
23
- renderTimeline: normalizeSessionRenderTimeline(renderTimeline),
24
- renderTimelineUpdatedAt: new Date().toISOString(),
25
- },
26
- });
37
+ renderTimeline: normalizeSessionRenderTimeline(renderTimeline),
38
+ };
39
+ const path = getTuiSessionTimelinePath(projectMarchDir, sessionId);
40
+ mkdirSync(dirname(path), { recursive: true });
41
+ writeFileSync(path, JSON.stringify(state, null, 2), "utf8");
42
+ return { path, renderTimeline: state.renderTimeline, state };
27
43
  }
28
44
 
29
45
  export function normalizeSessionRenderTimeline(events) {
@@ -33,15 +49,28 @@ export function normalizeSessionRenderTimeline(events) {
33
49
  .map((event) => ({ method: event.method, args: event.args, at: event.at ?? null }));
34
50
  }
35
51
 
36
- function readPersistedRenderTimelineInfo({ projectMarchDir, sessionId }) {
52
+ function loadTuiRenderTimeline({ projectMarchDir, sessionId }) {
53
+ try {
54
+ const path = getTuiSessionTimelinePath(projectMarchDir, sessionId);
55
+ if (!existsSync(path)) return null;
56
+ const state = JSON.parse(readFileSync(path, "utf8"));
57
+ if (state?.version !== TUI_SESSION_TIMELINE_VERSION) return null;
58
+ return { path, renderTimeline: normalizeSessionRenderTimeline(state.renderTimeline), source: "tui" };
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ function loadLegacyCoreRenderTimeline({ projectMarchDir, sessionId }) {
37
65
  try {
38
66
  const path = getMarchSessionStatePath(projectMarchDir, sessionId);
39
- if (!existsSync(path)) return { hasUiOwnedTimeline: false };
67
+ if (!existsSync(path)) return null;
40
68
  const raw = JSON.parse(readFileSync(path, "utf8"));
41
69
  const rawTimeline = normalizeSessionRenderTimeline(raw.renderTimeline);
42
- return { hasUiOwnedTimeline: Boolean(raw.renderTimelineUpdatedAt) || rawTimeline.length > 0 };
70
+ if (!raw.renderTimelineUpdatedAt && rawTimeline.length === 0) return null;
71
+ return { path, renderTimeline: rawTimeline, source: "legacy-core-render" };
43
72
  } catch {
44
- return { hasUiOwnedTimeline: false };
73
+ return null;
45
74
  }
46
75
  }
47
76
 
@@ -0,0 +1,22 @@
1
+ import { loadMarchSessionStateForPiBackend } from "../session/state/march-session-state.mjs";
2
+
3
+ export function loadOptionalWorkspaceMarchSessionState({ runtime, session }) {
4
+ try {
5
+ return loadWorkspaceMarchSessionState({ runtime, session });
6
+ } catch {
7
+ return null;
8
+ }
9
+ }
10
+
11
+ export function loadWorkspaceMarchSessionState({ runtime, session }) {
12
+ const stored = loadMarchSessionStateForPiBackend({
13
+ projectMarchDir: runtime.projectMarchDir,
14
+ sessionId: session.id,
15
+ sessionRef: session.path,
16
+ });
17
+ if (!stored) throw new Error(`March session state not found for ${session.id}; refusing partial resume`);
18
+ if (stored.state.cwd && stored.state.cwd !== runtime.runner.engine.cwd) {
19
+ throw new Error(`March session state cwd mismatch for ${session.id}: ${stored.state.cwd}`);
20
+ }
21
+ return stored.state;
22
+ }
@@ -1,12 +1,15 @@
1
- import { join } from "node:path";
2
- import { loadMarchSessionStateForPiBackend } from "../session/state/march-session-state.mjs";
1
+ import { basename, join } from "node:path";
2
+ import { createSessionControllerLeaseManager } from "../session/control/controller-lease.mjs";
3
+ import { loadOptionalWorkspaceMarchSessionState, loadWorkspaceMarchSessionState } from "./session-restore.mjs";
4
+ import { createViewOnlyRuntime } from "./view-runtime.mjs";
3
5
 
4
- export function createWorkspaceSessionSupervisor({ initialRuntime, createProjectRuntime, viewSessionState = initialRuntime?.sessionState, onActivate = null }) {
6
+ export function createWorkspaceSessionSupervisor({ initialRuntime, createProjectRuntime, viewSessionState = initialRuntime?.sessionState, onActivate = null, controllerLeases = createSessionControllerLeaseManager({ cwd: initialRuntime?.cwd }) }) {
5
7
  if (!initialRuntime?.project?.projectId) throw new Error("initial workspace runtime is missing project metadata");
6
8
  if (typeof createProjectRuntime !== "function") throw new Error("createProjectRuntime is required");
7
9
 
8
10
  const runtimes = new Map();
9
11
  let active = initialRuntime;
12
+ let activeView = null;
10
13
  let disposed = false;
11
14
  rememberRuntime(initialRuntime);
12
15
 
@@ -18,15 +21,21 @@ export function createWorkspaceSessionSupervisor({ initialRuntime, createProject
18
21
  if (prop === "activateWorkspaceSessionById") return activateWorkspaceSessionById;
19
22
  if (prop === "startNewWorkspaceSession") return startNewWorkspaceSession;
20
23
  if (prop === "refreshActiveRuntime") return refreshActiveRuntime;
21
- const value = active.runner[prop];
22
- return typeof value === "function" ? value.bind(active.runner) : value;
24
+ if (prop === "viewWorkspaceSession") return viewWorkspaceSession;
25
+ if (prop === "runTurn") return runActiveTurn;
26
+ if (prop === "abort") return abortActiveTurn;
27
+ if (prop === "startNewSession") return startActiveNewSession;
28
+ if (prop === "switchPiSession") return switchActivePiSession;
29
+ const current = getActive();
30
+ const value = current.runner[prop];
31
+ return typeof value === "function" ? value.bind(current.runner) : value;
23
32
  },
24
33
  set(_target, prop, value) {
25
- active.runner[prop] = value;
34
+ getActive().runner[prop] = value;
26
35
  return true;
27
36
  },
28
37
  has(_target, prop) {
29
- return prop === "dispose" || prop === "getActiveWorkspaceRuntime" || prop === "activateWorkspaceSession" || prop === "activateWorkspaceSessionById" || prop === "startNewWorkspaceSession" || prop === "refreshActiveRuntime" || prop in active.runner;
38
+ return prop === "dispose" || prop === "getActiveWorkspaceRuntime" || prop === "activateWorkspaceSession" || prop === "activateWorkspaceSessionById" || prop === "startNewWorkspaceSession" || prop === "refreshActiveRuntime" || prop === "viewWorkspaceSession" || prop === "runTurn" || prop === "abort" || prop === "startNewSession" || prop === "switchPiSession" || prop in getActive().runner;
30
39
  },
31
40
  });
32
41
 
@@ -37,6 +46,7 @@ export function createWorkspaceSessionSupervisor({ initialRuntime, createProject
37
46
  getRunningTurns,
38
47
  getRuntimeSummaries,
39
48
  refreshActiveRuntime,
49
+ viewWorkspaceSession,
40
50
  activateWorkspaceSession,
41
51
  activateWorkspaceSessionById,
42
52
  startNewWorkspaceSession,
@@ -44,7 +54,7 @@ export function createWorkspaceSessionSupervisor({ initialRuntime, createProject
44
54
  };
45
55
 
46
56
  function getActive() {
47
- return active;
57
+ return activeView ?? active;
48
58
  }
49
59
 
50
60
  function hasRunningTurn() {
@@ -65,9 +75,10 @@ export function createWorkspaceSessionSupervisor({ initialRuntime, createProject
65
75
  }
66
76
 
67
77
  function refreshActiveRuntime() {
68
- rememberRuntime(active);
69
- mirrorSessionState(viewSessionState, active.sessionState);
70
- onActivate?.({ projectId: active.project.projectId, sessionId: getRuntimeSessionId(active), runtime: active });
78
+ const current = getActive();
79
+ if (!current.viewOnly) rememberRuntime(current);
80
+ mirrorSessionState(viewSessionState, current.sessionState);
81
+ onActivate?.({ projectId: current.project.projectId, sessionId: getRuntimeSessionId(current), runtime: current });
71
82
  }
72
83
 
73
84
  async function activateWorkspaceSessionById({ projects = [], projectId, sessionId }) {
@@ -79,35 +90,64 @@ export function createWorkspaceSessionSupervisor({ initialRuntime, createProject
79
90
  }
80
91
 
81
92
  async function startNewWorkspaceSession(project) {
93
+ activeView = null;
94
+ const previous = active;
82
95
  const runtime = await getIdleRuntimeForProject(project);
83
96
  active = runtime;
84
97
  const result = await active.runner.startNewSession();
85
- if (!result?.cancelled && result?.sessionId) syncSessionState(active, result.sessionId);
98
+ if (!result?.cancelled && result?.sessionId) {
99
+ syncSessionState(active, result.sessionId);
100
+ replaceRuntimeLease(active, acquireRuntimeLease(active, { sessionId: result.sessionId, sessionPath: result.sessionFile ?? null }));
101
+ releaseIdleRuntimeLease(previous, active);
102
+ }
86
103
  rememberRuntime(active);
87
104
  mirrorSessionState(viewSessionState, active.sessionState);
88
105
  onActivate?.({ projectId: active.project.projectId, sessionId: getRuntimeSessionId(active), runtime: active, restoreState: null });
89
106
  return { runtime: active, result };
90
107
  }
91
108
 
92
- async function activateWorkspaceSession({ project, session = null }) {
109
+ async function activateWorkspaceSession({ project, session = null, force = false }) {
93
110
  if (disposed) throw new Error("workspace supervisor is already disposed");
94
111
  if (!project?.projectId) throw new Error("workspace project is required");
95
112
 
96
113
  let runtime = session?.id ? runtimes.get(runtimeKey(project.projectId, session.id)) : findIdleRuntime(project.projectId);
97
114
  if (!runtime) runtime = await createProjectRuntime(project);
98
115
 
99
- let restoreState = null;
100
- if (session?.path && getRuntimeSessionId(runtime) !== session.id) {
101
- restoreState = loadWorkspaceMarchSessionState({ runtime, session });
102
- await runtime.runner.switchPiSession(session.path, restoreState);
103
- syncSessionState(runtime, session.id);
116
+ const targetSessionId = session?.id ?? null;
117
+ const lease = targetSessionId ? acquireRuntimeLease(runtime, { sessionId: targetSessionId, sessionPath: session?.path ?? null }, { force }) : null;
118
+ const previous = active;
119
+ try {
120
+ let restoreState = null;
121
+ if (session?.path && getRuntimeSessionId(runtime) !== targetSessionId) {
122
+ restoreState = loadWorkspaceMarchSessionState({ runtime, session });
123
+ await runtime.runner.switchPiSession(session.path, restoreState);
124
+ }
125
+ if (targetSessionId) syncSessionState(runtime, targetSessionId);
126
+
127
+ replaceRuntimeLease(runtime, lease);
128
+ releaseIdleRuntimeLease(previous, runtime);
129
+ activeView = null;
130
+ active = runtime;
131
+ rememberRuntime(runtime);
132
+ mirrorSessionState(viewSessionState, runtime.sessionState);
133
+ onActivate?.({ projectId: runtime.project.projectId, sessionId: getRuntimeSessionId(runtime), runtime, restoreState });
134
+ return active;
135
+ } catch (err) {
136
+ lease?.release?.();
137
+ throw err;
104
138
  }
139
+ }
105
140
 
106
- active = runtime;
107
- rememberRuntime(runtime);
108
- mirrorSessionState(viewSessionState, runtime.sessionState);
109
- onActivate?.({ projectId: runtime.project.projectId, sessionId: getRuntimeSessionId(runtime), runtime, restoreState });
110
- return active;
141
+ function viewWorkspaceSession({ project, session }) {
142
+ if (disposed) throw new Error("workspace supervisor is already disposed");
143
+ if (!project?.projectId || !session?.id) throw new Error("workspace session is required");
144
+ const baseRuntime = findIdleRuntime(project.projectId, { allowSessionRuntime: false }) ?? active;
145
+ const restoreState = session.path ? loadOptionalWorkspaceMarchSessionState({ runtime: { ...baseRuntime, project, projectMarchDir: join(project.rootPath, ".march") }, session }) : null;
146
+ const view = createViewOnlyRuntime({ project, session, baseRuntime, restoreState });
147
+ activeView = view;
148
+ mirrorSessionState(viewSessionState, view.sessionState);
149
+ onActivate?.({ projectId: project.projectId, sessionId: session.id, runtime: view, restoreState, viewOnly: true });
150
+ return view;
111
151
  }
112
152
 
113
153
  async function getIdleRuntimeForProject(project) {
@@ -137,28 +177,98 @@ export function createWorkspaceSessionSupervisor({ initialRuntime, createProject
137
177
  disposed = true;
138
178
  const uniqueRuntimes = new Set(runtimes.values());
139
179
  await Promise.all(Array.from(uniqueRuntimes, async (runtime) => {
180
+ releaseRuntimeLease(runtime);
140
181
  await runtime.runner.dispose?.();
141
182
  runtime.memoryStore?.close?.();
142
183
  }));
143
184
  }
144
- }
145
185
 
146
- function loadWorkspaceMarchSessionState({ runtime, session }) {
147
- const stored = loadMarchSessionStateForPiBackend({
148
- projectMarchDir: runtime.projectMarchDir,
149
- sessionId: session.id,
150
- sessionRef: session.path,
151
- });
152
- if (!stored) throw new Error(`March session state not found for ${session.id}; refusing partial resume`);
153
- if (stored.state.cwd && stored.state.cwd !== runtime.runner.engine.cwd) {
154
- throw new Error(`March session state cwd mismatch for ${session.id}: ${stored.state.cwd}`);
186
+ async function runActiveTurn(...args) {
187
+ if (activeView) throw new Error("This session is view-only. Use /session and Take over control before sending prompts.");
188
+ ensureRuntimeLease(active);
189
+ return await active.runner.runTurn(...args);
190
+ }
191
+
192
+ function abortActiveTurn(...args) {
193
+ if (activeView) throw new Error("This session is view-only; there is no local turn to abort.");
194
+ ensureRuntimeLease(active);
195
+ return active.runner.abort(...args);
196
+ }
197
+
198
+ async function startActiveNewSession(...args) {
199
+ activeView = null;
200
+ const result = await active.runner.startNewSession(...args);
201
+ if (!result?.cancelled && result?.sessionId) {
202
+ syncSessionState(active, result.sessionId);
203
+ replaceRuntimeLease(active, acquireRuntimeLease(active, { sessionId: result.sessionId, sessionPath: result.sessionFile ?? null }));
204
+ rememberRuntime(active);
205
+ mirrorSessionState(viewSessionState, active.sessionState);
206
+ }
207
+ return result;
208
+ }
209
+
210
+ async function switchActivePiSession(sessionPath, restoreState = null, ...args) {
211
+ activeView = null;
212
+ const sessionId = sessionIdFromSessionPath(sessionPath);
213
+ const lease = acquireRuntimeLease(active, { sessionId, sessionPath });
214
+ try {
215
+ const result = await active.runner.switchPiSession(sessionPath, restoreState, ...args);
216
+ if (result?.cancelled) {
217
+ lease.release?.();
218
+ return result;
219
+ }
220
+ const stats = active.runner.getSessionStats?.() ?? {};
221
+ syncSessionState(active, stats.sessionId ?? sessionId);
222
+ replaceRuntimeLease(active, lease);
223
+ rememberRuntime(active);
224
+ mirrorSessionState(viewSessionState, active.sessionState);
225
+ return result;
226
+ } catch (err) {
227
+ lease.release?.();
228
+ throw err;
229
+ }
230
+ }
231
+
232
+ function acquireRuntimeLease(runtime, session, options = {}) {
233
+ if (!session?.sessionId) return null;
234
+ return controllerLeases.acquire({
235
+ sessionId: session.sessionId,
236
+ sessionPath: session.sessionPath ?? getRuntimeSessionFile(runtime),
237
+ projectMarchDir: runtime.projectMarchDir,
238
+ }, options);
239
+ }
240
+
241
+ function ensureRuntimeLease(runtime) {
242
+ const sessionId = getRuntimeSessionId(runtime);
243
+ if (!sessionId) return null;
244
+ if (!runtime.controllerLease) replaceRuntimeLease(runtime, acquireRuntimeLease(runtime, { sessionId }));
245
+ runtime.controllerLease.assertOwned();
246
+ return runtime.controllerLease;
247
+ }
248
+
249
+ function replaceRuntimeLease(runtime, lease) {
250
+ if (runtime.controllerLease === lease) return;
251
+ runtime.controllerLease?.release?.();
252
+ runtime.controllerLease = lease;
253
+ }
254
+
255
+ function releaseRuntimeLease(runtime) {
256
+ runtime.controllerLease?.release?.();
257
+ runtime.controllerLease = null;
258
+ }
259
+
260
+ function releaseIdleRuntimeLease(runtime, nextRuntime) {
261
+ if (!runtime || runtime === nextRuntime || runtime.turnTask) return;
262
+ releaseRuntimeLease(runtime);
155
263
  }
156
- const { renderTimeline: _renderTimeline, ...contextState } = stored.state;
157
- return contextState;
158
264
  }
159
265
 
160
266
  function getRuntimeSessionId(runtime) {
161
- return runtime.runner.getSessionStats?.()?.sessionId ?? runtime.sessionState?.sessionId ?? null;
267
+ return runtime.sessionState?.sessionId ?? runtime.runner.getSessionStats?.()?.sessionId ?? null;
268
+ }
269
+
270
+ function getRuntimeSessionFile(runtime) {
271
+ return runtime.runner.getSessionStats?.()?.sessionFile ?? null;
162
272
  }
163
273
 
164
274
  function syncSessionState(runtime, sessionId) {
@@ -176,3 +286,7 @@ function mirrorSessionState(target, source) {
176
286
  function runtimeKey(projectId, sessionId = null) {
177
287
  return `${projectId}:${sessionId ?? ""}`;
178
288
  }
289
+
290
+ function sessionIdFromSessionPath(sessionPath) {
291
+ return basename(String(sessionPath)).replace(/\.jsonl?$/i, "");
292
+ }
@@ -0,0 +1,40 @@
1
+ import { join, resolve } from "node:path";
2
+
3
+ export function createViewOnlyRuntime({ project, session, baseRuntime, restoreState }) {
4
+ const projectRoot = resolveProjectRoot(project, baseRuntime);
5
+ const sessionState = {
6
+ sessionId: session.id,
7
+ sessionDir: join(projectRoot, ".march", "sessions", session.id),
8
+ };
9
+ const runner = {
10
+ engine: { cwd: projectRoot, turns: restoreState?.turns ?? [] },
11
+ runtimeState: { engine: { cwd: projectRoot } },
12
+ getSessionStats: () => ({ sessionId: session.id, sessionFile: session.path ?? null }),
13
+ estimateContextTokens: () => null,
14
+ canSwitchPiSession: () => false,
15
+ runTurn: rejectViewOnlyControl,
16
+ abort: rejectViewOnlyControl,
17
+ startNewSession: rejectViewOnlyControl,
18
+ switchPiSession: rejectViewOnlyControl,
19
+ };
20
+ return {
21
+ ...baseRuntime,
22
+ project,
23
+ cwd: projectRoot,
24
+ currentProject: project.displayName,
25
+ projectMarchDir: join(projectRoot, ".march"),
26
+ sessionsRoot: join(projectRoot, ".march", "sessions"),
27
+ sessionState,
28
+ runner,
29
+ turnTask: null,
30
+ viewOnly: true,
31
+ };
32
+ }
33
+
34
+ function rejectViewOnlyControl() {
35
+ throw new Error("This session is view-only. Use /session and Take over control before sending prompts.");
36
+ }
37
+
38
+ function resolveProjectRoot(project, fallbackRuntime) {
39
+ return project?.rootPath ? resolve(project.rootPath) : fallbackRuntime.cwd;
40
+ }