march-cli 0.1.37 → 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.
Files changed (58) hide show
  1. package/package.json +1 -1
  2. package/src/agent/runner/runner-utils.mjs +20 -0
  3. package/src/agent/runner.mjs +16 -17
  4. package/src/agent/runtime/remote-ui-client.mjs +0 -1
  5. package/src/agent/runtime/runner-process-client.mjs +2 -0
  6. package/src/agent/runtime/runner-process-factory.mjs +3 -4
  7. package/src/agent/runtime/runner-runtime-host.mjs +0 -2
  8. package/src/agent/runtime/ui-event-bridge.mjs +0 -2
  9. package/src/agent/session/session-options.mjs +1 -2
  10. package/src/agent/tools.mjs +2 -23
  11. package/src/agent/turn/turn-runner.mjs +4 -4
  12. package/src/cli/args.mjs +3 -3
  13. package/src/cli/commands/mode-command.mjs +1 -0
  14. package/src/cli/commands/registry/slash-command-registry.mjs +4 -3
  15. package/src/cli/fallback-ui.mjs +0 -2
  16. package/src/cli/input/mode-state.mjs +1 -1
  17. package/src/cli/repl-commands.mjs +1 -1
  18. package/src/cli/repl-loop.mjs +67 -19
  19. package/src/cli/session/pi-session-switch-command.mjs +11 -11
  20. package/src/cli/session/session-list-command.mjs +1 -1
  21. package/src/cli/session/session-source-command.mjs +0 -76
  22. package/src/cli/startup/app-runtime.mjs +103 -4
  23. package/src/cli/startup/create-runtime-runner.mjs +2 -1
  24. package/src/cli/startup/startup-session.mjs +3 -13
  25. package/src/cli/ui.mjs +0 -6
  26. package/src/cli/workspace/command.mjs +121 -0
  27. package/src/cli/workspace/output-router.mjs +127 -0
  28. package/src/cli/workspace/project-runtime.mjs +94 -0
  29. package/src/cli/workspace/runtime-session-state.mjs +9 -0
  30. package/src/config/features.mjs +0 -1
  31. package/src/extensions/lifecycle-adapter.mjs +3 -3
  32. package/src/main.mjs +11 -1
  33. package/src/notification/desktop-notifier.mjs +16 -8
  34. package/src/session/sidecar-sync.mjs +3 -17
  35. package/src/session/sidecar.mjs +40 -41
  36. package/src/session/state/march-session-state.mjs +175 -0
  37. package/src/session/state/march-session-sync.mjs +20 -0
  38. package/src/session/state/march-session-ui-state.mjs +60 -0
  39. package/src/web-ui/dist/assets/index-BQtl1uQs.css +1 -0
  40. package/src/web-ui/dist/assets/index-DrlJis_D.js +1845 -0
  41. package/src/web-ui/dist/index.html +13 -0
  42. package/src/web-ui/runtime-host.mjs +1 -2
  43. package/src/web-ui/src/components/timeline/TimelineBlocks.tsx +2 -10
  44. package/src/web-ui/src/mockData.ts +1 -8
  45. package/src/web-ui/src/model.ts +0 -2
  46. package/src/web-ui/src/runtime/client.ts +0 -1
  47. package/src/web-ui/src/runtime/runtimeTimeline.ts +1 -3
  48. package/src/web-ui/src/styles/shell.css +1 -2
  49. package/src/web-ui/src/timelineAdapter.ts +1 -2
  50. package/src/workspace/project-id.mjs +14 -0
  51. package/src/workspace/project-registry.mjs +74 -0
  52. package/src/workspace/session-index.mjs +102 -0
  53. package/src/workspace/supervisor.mjs +178 -0
  54. package/src/agent/pi-session/pi-session-sidecar-failure.mjs +0 -10
  55. package/src/cli/permissions.mjs +0 -103
  56. package/src/cli/session/session-switch-command.mjs +0 -1
  57. package/src/cli/tui/permission-request-ui.mjs +0 -18
  58. package/src/session/persist.mjs +0 -1
@@ -0,0 +1,94 @@
1
+ import { existsSync, mkdirSync } from "node:fs";
2
+ import { basename, join, resolve } from "node:path";
3
+ import { createRuntimeRunner } from "../startup/create-runtime-runner.mjs";
4
+ import { createCliShellRuntime } from "../../shell/cli-runtime.mjs";
5
+ import { createMarchAuthStorage } from "../../auth/storage.mjs";
6
+ import { discoverProjectExtensionPaths } from "../../extensions/discovery.mjs";
7
+ import { loadProjectLifecycleHookManifests } from "../../extensions/lifecycle-manifest.mjs";
8
+ import { loadKeybindings } from "../input/keybindings.mjs";
9
+ import { loadPromptTemplates } from "../input/prompt-templates.mjs";
10
+ import { loadOrCreateProjectId } from "../../workspace/project-id.mjs";
11
+ import { syncRuntimeSessionStateFromRunner } from "./runtime-session-state.mjs";
12
+
13
+ export async function createWorkspaceProjectRuntime({
14
+ project,
15
+ args,
16
+ config,
17
+ stateRoot,
18
+ memoryRoot,
19
+ profilePaths,
20
+ createMemoryStore,
21
+ provider,
22
+ serviceTier,
23
+ model,
24
+ remoteMemorySources,
25
+ createUi,
26
+ refreshStatusBar,
27
+ onNotificationActivation = null,
28
+ }) {
29
+ const cwd = project.rootPath;
30
+ const projectMarchDir = resolve(cwd, ".march");
31
+ if (!existsSync(projectMarchDir)) mkdirSync(projectMarchDir, { recursive: true });
32
+
33
+ const authConfig = createMarchAuthStorage({ provider: provider ?? "deepseek", providers: config.providers, cwd });
34
+ if (!authConfig.hasAuth) throw new Error(`no providers configured for project: ${cwd}`);
35
+
36
+ const namespace = loadOrCreateProjectId(projectMarchDir);
37
+ const extensionPaths = [
38
+ ...discoverProjectExtensionPaths(cwd),
39
+ ...args.extensions.map((extensionPath) => resolve(cwd, extensionPath)),
40
+ ];
41
+ const lifecycleManifests = loadProjectLifecycleHookManifests(cwd);
42
+ const keybindingConfig = loadKeybindings(cwd);
43
+ const promptTemplateConfig = loadPromptTemplates(cwd);
44
+ const projectShellRuntime = args.shellRuntime ? createCliShellRuntime({ cwd }) : null;
45
+ const sessionsRoot = join(projectMarchDir, "sessions");
46
+ const sessionState = { sessionId: Date.now().toString(36), sessionDir: null };
47
+ sessionState.sessionDir = join(sessionsRoot, sessionState.sessionId);
48
+ const contextDumpRoot = resolve(projectMarchDir, "context-dumps", sessionState.sessionId);
49
+ const memoryStore = createMemoryStore();
50
+ const ui = createUi(sessionState);
51
+ const runner = await createRuntimeRunner({
52
+ runnerOptions: {
53
+ cwd,
54
+ modelId: model,
55
+ provider,
56
+ serviceTier,
57
+ providers: config.providers,
58
+ config,
59
+ stateRoot,
60
+ memoryRoot,
61
+ profilePaths,
62
+ namespace,
63
+ projectMarchDir,
64
+ extensionPaths,
65
+ shellRuntime: Boolean(projectShellRuntime),
66
+ lifecycleHooks: lifecycleManifests.hooks,
67
+ lifecycleDiagnostics: lifecycleManifests.diagnostics,
68
+ modelContextDumper: { enabled: args.dumpContext, rootDir: contextDumpRoot },
69
+ remoteMemorySources,
70
+ notificationContext: { projectId: project.projectId },
71
+ },
72
+ ui,
73
+ shellRuntime: projectShellRuntime,
74
+ refreshStatusBar,
75
+ onNotificationActivation,
76
+ });
77
+ syncRuntimeSessionStateFromRunner(sessionState, runner, sessionsRoot);
78
+
79
+ return {
80
+ project,
81
+ cwd,
82
+ currentProject: basename(cwd),
83
+ runner,
84
+ ui,
85
+ memoryStore,
86
+ sessionState,
87
+ sessionsRoot,
88
+ projectMarchDir,
89
+ extensionPaths,
90
+ keybindingConfig,
91
+ promptTemplateConfig,
92
+ contextDumpRoot,
93
+ };
94
+ }
@@ -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,6 @@ const DEFAULTS = Object.freeze({
7
7
  "experimental.web_search": true,
8
8
  "experimental.web_fetch": true,
9
9
  "experimental.shell": true,
10
- "experimental.permissions": true,
11
10
  "ui.markdown_rendering": false,
12
11
  "ui.tool_expand_per_card": false,
13
12
  "agent.plan_mode": false,
@@ -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",
@@ -109,7 +109,7 @@ export function createMarchLifecycleAdapter({
109
109
  type: "info",
110
110
  message: hooks.size === 0
111
111
  ? "March lifecycle hook adapter is read-only; no March hooks are registered."
112
- : "March lifecycle hook adapter is read-only; registered hooks are permission-gated.",
112
+ : "March lifecycle hook adapter is read-only; registered hooks are policy-gated.",
113
113
  },
114
114
  ...adapterDiagnostics,
115
115
  ...getRuntimeDiagnostics(),
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({
@@ -77,6 +83,10 @@ export async function run(argv) {
77
83
  runner: app.runner,
78
84
  memoryStore: app.memoryStore,
79
85
  currentProject: app.currentProject,
86
+ currentProjectInfo: app.currentProjectInfo,
87
+ workspaceSupervisor: app.workspaceSupervisor,
88
+ workspaceOutputRouter: app.workspaceOutputRouter,
89
+ stateRoot,
80
90
  sessionState: app.sessionState,
81
91
  sessionsRoot: app.sessionsRoot,
82
92
  projectMarchDir: app.projectMarchDir,
@@ -12,6 +12,7 @@ export function createDesktopTurnNotifier({
12
12
  writeBell = () => process.stdout.write("\x07"),
13
13
  toastNotifier = nodeNotifier,
14
14
  config = {},
15
+ onActivation = null,
15
16
  } = {}) {
16
17
  const channels = resolveNotificationChannels(config);
17
18
  const minDurationMs = normalizeNonNegativeInteger(config.minDurationMs, 0);
@@ -31,7 +32,7 @@ export function createDesktopTurnNotifier({
31
32
  if (channels.desktop) {
32
33
  results.push({
33
34
  channel: "desktop",
34
- ...(await sendDesktopNotification({ platform, spawnProcess, toastNotifier, ...payload })),
35
+ ...(await sendDesktopNotification({ platform, spawnProcess, toastNotifier, onActivation, activation: normalizedEvent.activation, ...payload })),
35
36
  });
36
37
  }
37
38
  if (channels.bell) results.push({ channel: "bell", ...sendBellNotification({ writeBell }) });
@@ -52,14 +53,14 @@ export function createDesktopTurnNotifier({
52
53
  };
53
54
  }
54
55
 
55
- export async function sendDesktopNotification({ platform = process.platform, spawnProcess = spawn, toastNotifier = nodeNotifier, title, message, iconPath = DEFAULT_NOTIFICATION_ICON_PATH, sound = true }) {
56
+ export async function sendDesktopNotification({ platform = process.platform, spawnProcess = spawn, toastNotifier = nodeNotifier, title, message, iconPath = DEFAULT_NOTIFICATION_ICON_PATH, sound = true, activation = null, onActivation = null }) {
56
57
  if (platform !== "win32") return { ok: false, reason: "unsupported-platform" };
57
58
 
58
59
  const safeTitle = normalizeNotificationText(title) || "March";
59
60
  const safeMessage = normalizeNotificationText(message) || "Turn finished";
60
61
 
61
62
  try {
62
- const toastResult = await sendWindowsToastNotification({ toastNotifier, title: safeTitle, message: safeMessage, iconPath, sound });
63
+ const toastResult = await sendWindowsToastNotification({ toastNotifier, title: safeTitle, message: safeMessage, iconPath, sound, activation, onActivation });
63
64
  if (toastResult.ok) return { ok: true };
64
65
 
65
66
  const script = buildWindowsNotificationScript({ title: safeTitle, message: safeMessage, iconPath });
@@ -140,18 +141,19 @@ export function buildWindowsNotificationScript({ title, message, timeoutMs = DEF
140
141
  return buildWindowsBalloonScript({ title, message, timeoutMs, iconPath });
141
142
  }
142
143
 
143
- export function buildWindowsToastOptions({ title, message, iconPath = DEFAULT_NOTIFICATION_ICON_PATH, sound = true }) {
144
+ export function buildWindowsToastOptions({ title, message, iconPath = DEFAULT_NOTIFICATION_ICON_PATH, sound = true, activation = null }) {
144
145
  return {
145
146
  title,
146
147
  message,
147
148
  icon: iconPath,
148
149
  appID: "March",
149
150
  sound,
150
- wait: false,
151
+ wait: Boolean(activation),
152
+ ...(activation ? { activation } : {}),
151
153
  };
152
154
  }
153
155
 
154
- function sendWindowsToastNotification({ toastNotifier = nodeNotifier, title, message, iconPath, sound }) {
156
+ function sendWindowsToastNotification({ toastNotifier = nodeNotifier, title, message, iconPath, sound, activation = null, onActivation = null }) {
155
157
  return new Promise((resolve) => {
156
158
  const notify = toastNotifier?.notify;
157
159
  if (typeof notify !== "function") {
@@ -161,12 +163,13 @@ function sendWindowsToastNotification({ toastNotifier = nodeNotifier, title, mes
161
163
 
162
164
  let settled = false;
163
165
  const timeout = setTimeout(() => finish({ ok: false, reason: "toast-timeout" }), DEFAULT_BALLOON_TIMEOUT_MS + 5000);
164
- notify.call(toastNotifier, buildWindowsToastOptions({ title, message, iconPath, sound }), (err) => {
166
+ notify.call(toastNotifier, buildWindowsToastOptions({ title, message, iconPath, sound, activation }), (err, response, metadata) => {
165
167
  if (err) {
166
168
  finish({ ok: false, reason: err?.message ?? String(err) });
167
169
  return;
168
170
  }
169
- finish({ ok: true });
171
+ if (isNotificationActivation(response, metadata)) onActivation?.(activation);
172
+ finish({ ok: true, activated: isNotificationActivation(response, metadata) });
170
173
  });
171
174
 
172
175
  function finish(result) {
@@ -178,6 +181,11 @@ function sendWindowsToastNotification({ toastNotifier = nodeNotifier, title, mes
178
181
  });
179
182
  }
180
183
 
184
+ function isNotificationActivation(response, metadata) {
185
+ const text = `${response ?? ""} ${metadata?.activationType ?? ""} ${metadata?.activationKind ?? ""}`.toLowerCase();
186
+ return /activate|click|contentsclicked|buttonclicked/.test(text);
187
+ }
188
+
181
189
  function resolveNotificationChannels(config) {
182
190
  return {
183
191
  desktop: config.desktop !== false,
@@ -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
+ }