march-cli 0.1.40 → 0.1.42

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.40",
3
+ "version": "0.1.42",
4
4
  "description": "March CLI — terminal-native coding agent with context reconstruction",
5
5
  "type": "module",
6
6
  "main": "./src/main.mjs",
@@ -18,7 +18,7 @@ import { resolveRunnerSessionOptions } from "./session/session-options.mjs";
18
18
  import { createSessionBinding } from "./session/session-binding.mjs";
19
19
  import { maybeAutoNameSession } from "./session/session-auto-name.mjs";
20
20
  import { MARCH_BASE_TOOL_NAMES } from "./tool-names.mjs";
21
- import { runRunnerTurn } from "./turn/turn-runner.mjs";
21
+ import { MODEL_STREAM_IDLE_TIMEOUT_CODE, runRunnerTurn } from "./turn/turn-runner.mjs";
22
22
  import { beginLoggedTurn } from "./turn/turn-logging.mjs";
23
23
  import { appendFastVariants, createFastModelEntry, fromFastEntryModel, isFastProvider } from "./runner/fast-model.mjs";
24
24
  import { registerSuperGrokProvider } from "../supergrok/provider.mjs";
@@ -142,6 +142,7 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
142
142
  turnLog.endSuccess(result);
143
143
  return result;
144
144
  } catch (err) {
145
+ if (err?.code === MODEL_STREAM_IDLE_TIMEOUT_CODE) nextTurnContextMode = "continueExistingPiTranscript";
145
146
  notifyTurnEndDetached(turnNotifier, {
146
147
  status: "error",
147
148
  sessionName: engine.sessionName,
@@ -12,6 +12,8 @@ export function createTurnEventState() {
12
12
  assistantContextParts: [],
13
13
  activeToolContextPart: null,
14
14
  toolCalls: [],
15
+ lastAssistantStopReason: null,
16
+ lastAssistantErrorMessage: null,
15
17
  };
16
18
  }
17
19
 
@@ -19,6 +21,10 @@ export function handleRunnerSessionEvent(event, { ui, engine, state }) {
19
21
  if (event.type === "message_update" && event.assistantMessageEvent) {
20
22
  handleAssistantMessageEvent(event.assistantMessageEvent, { ui, state });
21
23
  }
24
+ if (event.type === "message_end" && event.message?.role === "assistant") {
25
+ state.lastAssistantStopReason = event.message.stopReason ?? null;
26
+ state.lastAssistantErrorMessage = event.message.errorMessage ?? null;
27
+ }
22
28
  if (event.type === "tool_execution_start") {
23
29
  closeAssistantReply({ ui, state });
24
30
  appendToolStartContext(state, event.toolName, event.args);
@@ -2,6 +2,8 @@ import { formatRecallHints } from "../../memory/markdown-store.mjs";
2
2
  import { resolveImageAttachmentReferences } from "../../session/attachment-references.mjs";
3
3
  import { closeAssistantReply, compactAssistantContext, createTurnEventState, handleRunnerSessionEvent } from "./turn-events.mjs";
4
4
 
5
+ export const MODEL_STREAM_IDLE_TIMEOUT_CODE = "MODEL_STREAM_IDLE_TIMEOUT";
6
+
5
7
  export async function runRunnerTurn({
6
8
  prompt,
7
9
  userMessage,
@@ -26,17 +28,33 @@ export async function runRunnerTurn({
26
28
  const activeSession = sessionBinding.get();
27
29
  const turnState = createTurnEventState();
28
30
  const midTurnRecallHints = [];
31
+ const idleWatchdog = createModelStreamIdleWatchdog({ session: activeSession, logger, setPhase });
29
32
  ui.turnStart();
30
33
  setPhase?.("subscribed");
31
34
  logger?.event("turn.ui.start");
32
35
 
33
36
  const unsubscribe = activeSession.subscribe((event) => {
34
37
  logSessionEvent(logger, event);
35
- if (event.type === "tool_execution_start") setPhase?.(`tool_running:${event.toolName ?? "unknown"}`);
36
- if (event.type === "tool_execution_end") setPhase?.("model_streaming");
37
- if (event.type === "auto_retry_start") setPhase?.("retry_wait");
38
- if (event.type === "auto_retry_end") setPhase?.("model_streaming");
39
- if (event.type === "message_update") setPhase?.("model_streaming");
38
+ if (event.type === "tool_execution_start") {
39
+ idleWatchdog.pause();
40
+ setPhase?.(`tool_running:${event.toolName ?? "unknown"}`);
41
+ }
42
+ if (event.type === "tool_execution_end") {
43
+ setPhase?.("model_streaming");
44
+ idleWatchdog.arm("tool_execution_end");
45
+ }
46
+ if (event.type === "auto_retry_start") {
47
+ idleWatchdog.pause();
48
+ setPhase?.("retry_wait");
49
+ }
50
+ if (event.type === "auto_retry_end") {
51
+ setPhase?.("model_streaming");
52
+ idleWatchdog.arm("auto_retry_end");
53
+ }
54
+ if (event.type === "message_update") {
55
+ setPhase?.("model_streaming");
56
+ idleWatchdog.arm("message_update");
57
+ }
40
58
  handleRunnerSessionEvent(event, { ui, engine, state: turnState });
41
59
  if (event.type === "tool_execution_start") {
42
60
  const hints = flushAssistantRecall({ memoryStore, engine, turnState, currentProject });
@@ -59,11 +77,14 @@ export async function runRunnerTurn({
59
77
  logger?.event("model.prompt.start", { contextMode });
60
78
  try {
61
79
  if (contextMode === "rebuild") resetPiMessageHistory(activeSession);
62
- await activeSession.prompt(
80
+ idleWatchdog.arm("model_request");
81
+ await idleWatchdog.watch(activeSession.prompt(
63
82
  contextMode === "continueExistingPiTranscript" ? (userMessage ?? prompt) : prompt,
64
83
  attachmentReferences.images.length > 0 ? { images: attachmentReferences.images } : undefined,
65
- );
84
+ ));
85
+ throwIfAssistantEndedWithError(turnState);
66
86
  } finally {
87
+ idleWatchdog.clear();
67
88
  setModelCallKind("model");
68
89
  logger?.event("model.prompt.end");
69
90
  }
@@ -86,11 +107,70 @@ export async function runRunnerTurn({
86
107
  return { draft: turnState.draft };
87
108
  } finally {
88
109
  logger?.event("turn.ui.end");
110
+ idleWatchdog.clear();
89
111
  ui.turnEnd();
90
112
  unsubscribe();
91
113
  }
92
114
  }
93
115
 
116
+ function createModelStreamIdleWatchdog({ session, logger, setPhase }) {
117
+ const timeoutMs = getModelStreamIdleTimeoutMs();
118
+ let timer = null;
119
+ let timedOut = false;
120
+ let rejectIdle = null;
121
+ const idlePromise = new Promise((_, reject) => {
122
+ rejectIdle = reject;
123
+ });
124
+
125
+ return {
126
+ arm(reason) {
127
+ if (timeoutMs <= 0 || timedOut) return;
128
+ clearTimer();
129
+ timer = setTimeout(() => {
130
+ timedOut = true;
131
+ setPhase?.("model_idle_timeout");
132
+ logger?.event("model.stream.idle_timeout", { timeoutMs, reason });
133
+ try { session.abortRetry?.(); } catch {}
134
+ try { session.abort?.(); } catch {}
135
+ rejectIdle(createModelStreamIdleTimeoutError(timeoutMs, reason));
136
+ }, timeoutMs);
137
+ },
138
+ pause: clearTimer,
139
+ clear: clearTimer,
140
+ async watch(promise) {
141
+ const guarded = Promise.resolve(promise);
142
+ guarded.catch(() => {});
143
+ return await Promise.race([guarded, idlePromise]);
144
+ },
145
+ };
146
+
147
+ function clearTimer() {
148
+ if (!timer) return;
149
+ clearTimeout(timer);
150
+ timer = null;
151
+ }
152
+ }
153
+
154
+ function createModelStreamIdleTimeoutError(timeoutMs, reason) {
155
+ const error = new Error(`Model stream idle timeout after ${timeoutMs}ms (${reason}); aborted the current turn`);
156
+ error.code = MODEL_STREAM_IDLE_TIMEOUT_CODE;
157
+ return error;
158
+ }
159
+
160
+ function getModelStreamIdleTimeoutMs() {
161
+ const raw = process.env.MARCH_MODEL_STREAM_IDLE_TIMEOUT_MS;
162
+ if (raw === "0" || raw === "false" || raw === "no") return 0;
163
+ const parsed = raw ? Number.parseInt(raw, 10) : 18000;
164
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 18000;
165
+ }
166
+
167
+ function throwIfAssistantEndedWithError(turnState) {
168
+ if (turnState.lastAssistantStopReason !== "error") return;
169
+ const error = new Error(turnState.lastAssistantErrorMessage || "Model provider returned an error");
170
+ error.code = "MODEL_PROVIDER_ERROR";
171
+ throw error;
172
+ }
173
+
94
174
  function queueMidTurnRecallHints(session, hints, logger) {
95
175
  const content = formatRecallHints("assistant", hints);
96
176
  if (!content) return;
package/src/cli/args.mjs CHANGED
@@ -69,6 +69,7 @@ Usage:
69
69
  march [options] (starts REPL)
70
70
  march login [provider] Login to an OAuth provider
71
71
  march provider --config Configure provider credentials
72
+ march provider remove Remove a configured provider interactively
72
73
  march provider share [id] Share a provider profile
73
74
  march provider accept <token>
74
75
  march web [path] Start the local Web UI session manager
@@ -104,6 +104,10 @@ export async function runInteractiveRepl({
104
104
  trimmed = templateResult.prompt;
105
105
  }
106
106
 
107
+ if (turnActive.viewOnly) {
108
+ ui.writeln("This session is view-only. Use /session and Take over control before sending prompts.");
109
+ continue;
110
+ }
107
111
  if (turnActive.turnTask) {
108
112
  ui.writeln("This session is still running. Use /session to start or inspect another session.");
109
113
  continue;
@@ -176,7 +176,10 @@ export async function createCliAppRuntime({ args, config, cwd, argv, stateRoot }
176
176
  onNotificationActivation,
177
177
  });
178
178
  },
179
- onActivate: ({ projectId, sessionId }) => outputRouter.setActiveSession(projectId, sessionId, { renderTimeline: loadStoredRenderTimeline(projectMarchDirs.get(projectId), sessionId) }),
179
+ onActivate: ({ projectId, sessionId, runtime }) => {
180
+ if (runtime?.projectMarchDir) projectMarchDirs.set(projectId, runtime.projectMarchDir);
181
+ outputRouter.setActiveSession(projectId, sessionId, { renderTimeline: loadStoredRenderTimeline(projectMarchDirs.get(projectId), sessionId) });
182
+ },
180
183
  });
181
184
  runner = workspaceSupervisor.runner;
182
185
 
@@ -3,6 +3,7 @@ import { resolve } from "node:path";
3
3
  import { brightBlack } from "../tui/ui-theme.mjs";
4
4
  import { registerProject, listRegisteredProjects } from "../../workspace/project-registry.mjs";
5
5
  import { buildWorkspaceSessionSelectItems, listWorkspaceSessions, workspaceSessionSearchText } from "../../workspace/session-index.mjs";
6
+ import { SessionControllerLeaseConflictError } from "../../session/control/controller-lease.mjs";
6
7
 
7
8
  export const WORKSPACE_SLASH_COMMANDS = [
8
9
  {
@@ -93,6 +94,9 @@ export async function handleSessionCommand({ stateRoot, currentProjectId, runner
93
94
  ctxRenderActiveSession({ workspaceOutputRouter, projectId: item.project.projectId, sessionId: item.session.id });
94
95
  return { handled: true, refreshContextTokens: true, activeChanged: true };
95
96
  } catch (err) {
97
+ if (err instanceof SessionControllerLeaseConflictError) {
98
+ return await handleSessionControllerConflict({ err, item, workspaceSupervisor, workspaceOutputRouter, ui });
99
+ }
96
100
  ui.writeln(`Error: ${err.message}`);
97
101
  return { handled: true };
98
102
  }
@@ -101,6 +105,46 @@ export async function handleSessionCommand({ stateRoot, currentProjectId, runner
101
105
  return { handled: true };
102
106
  }
103
107
 
108
+ async function handleSessionControllerConflict({ err, item, workspaceSupervisor, workspaceOutputRouter, ui }) {
109
+ ui.writeln(err.message);
110
+ if (!ui.selectList) return { handled: true };
111
+ const choice = await ui.selectList({
112
+ items: [
113
+ { value: "view-only", label: "View only", description: "inspect this session without taking control" },
114
+ { value: "take-over", label: "Take over control", description: "steal the controller lease for this session" },
115
+ { value: "cancel", label: "Cancel", description: "leave current controller unchanged" },
116
+ ],
117
+ selectedIndex: 0,
118
+ width: 70,
119
+ suppressInitialConfirm: true,
120
+ });
121
+ if (choice?.value === "view-only") {
122
+ try {
123
+ await workspaceSupervisor.viewWorkspaceSession({ project: item.project, session: item.session });
124
+ ctxRenderActiveSession({ workspaceOutputRouter, projectId: item.project.projectId, sessionId: item.session.id });
125
+ ui.writeln(`Viewing session: ${item.project.displayName} / ${item.session.name || item.session.id}`);
126
+ ui.writeln("View-only mode: prompts are disabled until you take over control.");
127
+ return { handled: true, refreshContextTokens: true, activeChanged: true };
128
+ } catch (viewErr) {
129
+ ui.writeln(`Error: ${viewErr.message}`);
130
+ return { handled: true };
131
+ }
132
+ }
133
+ if (choice?.value !== "take-over") {
134
+ ui.writeln("Session unchanged.");
135
+ return { handled: true };
136
+ }
137
+ try {
138
+ await workspaceSupervisor.activateWorkspaceSession({ project: item.project, session: item.session, force: true });
139
+ ctxRenderActiveSession({ workspaceOutputRouter, projectId: item.project.projectId, sessionId: item.session.id });
140
+ ui.writeln(`Took over session: ${item.project.displayName} / ${item.session.name || item.session.id}`);
141
+ return { handled: true, refreshContextTokens: true, activeChanged: true };
142
+ } catch (takeoverErr) {
143
+ ui.writeln(`Error: ${takeoverErr.message}`);
144
+ return { handled: true };
145
+ }
146
+ }
147
+
104
148
  function annotateWorkspaceItems(items, runtimeSummaries) {
105
149
  if (!runtimeSummaries.length) return items;
106
150
  const running = new Set(runtimeSummaries.filter((runtime) => runtime.running).map((runtime) => `${runtime.projectId}:${runtime.sessionId}`));
@@ -38,6 +38,26 @@ export function upsertProviderProfile({ path = globalConfigJsonPath(), id, type,
38
38
  return config;
39
39
  }
40
40
 
41
+ export function removeProviderProfile({ path = globalConfigJsonPath(), id }) {
42
+ const config = readConfigJson(path);
43
+ const providers = config.providers && typeof config.providers === "object" && !Array.isArray(config.providers)
44
+ ? config.providers
45
+ : {};
46
+ const hadProviderProfile = Object.prototype.hasOwnProperty.call(providers, id);
47
+ const wasSelectedProvider = config.provider === id;
48
+ if (hadProviderProfile) delete providers[id];
49
+ if (Object.keys(providers).length) config.providers = providers;
50
+ else delete config.providers;
51
+ if (wasSelectedProvider) {
52
+ delete config.provider;
53
+ delete config.model;
54
+ delete config.serviceTier;
55
+ }
56
+ if (!hadProviderProfile && !wasSelectedProvider) return false;
57
+ writeConfigJson(path, config);
58
+ return true;
59
+ }
60
+
41
61
  export function upsertSharedProviderProfile({ path = globalConfigJsonPath(), id, provider }) {
42
62
  const config = readConfigJson(path);
43
63
  const providers = config.providers && typeof config.providers === "object" && !Array.isArray(config.providers)
@@ -2,6 +2,7 @@ import { homedir } from "node:os";
2
2
  import { runProviderConfigCommand } from "./config-command.mjs";
3
3
  import { runProviderShareCommand } from "./share-command.mjs";
4
4
  import { runProviderAcceptCommand } from "./accept-command.mjs";
5
+ import { runProviderRemoveCommand } from "./remove-command.mjs";
5
6
 
6
7
  export async function runProviderCommand(args, { homeDir = homedir(), stderr = process.stderr } = {}) {
7
8
  if (args.providerConfig) return await runProviderConfigCommand({ homeDir });
@@ -16,6 +17,9 @@ export async function runProviderCommand(args, { homeDir = homedir(), stderr = p
16
17
  if (args.command.args[0] === "accept") {
17
18
  return await runProviderAcceptCommand({ homeDir, token: args.command.args[1] });
18
19
  }
19
- stderr.write("Usage: march provider --config | march provider share [id] | march provider accept <token>\n");
20
+ if (args.command.args[0] === "remove" || args.command.args[0] === "uninstall") {
21
+ return await runProviderRemoveCommand({ homeDir, providerId: args.command.args[1] });
22
+ }
23
+ stderr.write("Usage: march provider --config | march provider share [id] | march provider accept <token> | march provider remove\n");
20
24
  return 1;
21
25
  }
@@ -0,0 +1,129 @@
1
+ import { createInterface } from "node:readline";
2
+ import { AuthStorage } from "@earendil-works/pi-coding-agent";
3
+ import { globalConfigJsonPath, readConfigJson, removeProviderProfile } from "../config/config-json.mjs";
4
+ import { getMarchAuthPath } from "../auth/storage.mjs";
5
+ import { selectWithKeyboard } from "../cli/input/select-with-keyboard.mjs";
6
+ import { getProviderLabel } from "./presets.mjs";
7
+
8
+ export async function runProviderRemoveCommand({
9
+ homeDir,
10
+ providerId,
11
+ input = process.stdin,
12
+ output = process.stdout,
13
+ select = selectWithKeyboard,
14
+ confirm = confirmRemoval,
15
+ authStorage = AuthStorage.create(getMarchAuthPath(homeDir)),
16
+ } = {}) {
17
+ const removableProviders = listRemovableProviders({ homeDir, authStorage });
18
+ const selectedProviderId = providerId ?? await selectProviderToRemove({ removableProviders, input, output, select });
19
+ if (!selectedProviderId) {
20
+ if (!removableProviders.length) {
21
+ output.write("No configured providers to remove.\nRun `march provider --config` to add one.\n");
22
+ } else {
23
+ output.write("Provider removal cancelled.\n");
24
+ }
25
+ return 1;
26
+ }
27
+
28
+ const provider = removableProviders.find((item) => item.id === selectedProviderId) ?? {
29
+ id: selectedProviderId,
30
+ label: getProviderLabel(selectedProviderId),
31
+ sources: [],
32
+ };
33
+ const confirmed = await confirm({ input, output, provider });
34
+ if (!confirmed) {
35
+ output.write("Provider removal cancelled.\n");
36
+ return 1;
37
+ }
38
+
39
+ const configRemoved = removeProviderProfile({ path: globalConfigJsonPath(homeDir), id: selectedProviderId });
40
+ const credentialRemoved = removeProviderCredential({ authStorage, id: selectedProviderId });
41
+ if (!configRemoved && !credentialRemoved) {
42
+ output.write(`Provider not found: ${selectedProviderId}\n`);
43
+ return 1;
44
+ }
45
+
46
+ output.write(`Removed provider: ${provider.label} (${selectedProviderId})\n`);
47
+ return 0;
48
+ }
49
+
50
+ export function listRemovableProviders({ homeDir, authStorage }) {
51
+ const config = readConfigJson(globalConfigJsonPath(homeDir));
52
+ const providers = config.providers && typeof config.providers === "object" && !Array.isArray(config.providers)
53
+ ? config.providers
54
+ : {};
55
+ const ids = new Set(Object.keys(providers));
56
+ for (const id of safeListAuthProviders(authStorage)) ids.add(id);
57
+ return [...ids].sort((a, b) => getProviderLabel(a).localeCompare(getProviderLabel(b))).map((id) => {
58
+ const sources = [];
59
+ if (Object.prototype.hasOwnProperty.call(providers, id)) sources.push("config");
60
+ if (safeHasAuthProvider(authStorage, id)) sources.push("credential");
61
+ return {
62
+ id,
63
+ label: getProviderLabel(id),
64
+ sources,
65
+ };
66
+ });
67
+ }
68
+
69
+ async function selectProviderToRemove({ removableProviders, input, output, select }) {
70
+ if (!removableProviders.length) return null;
71
+ return await select({
72
+ input,
73
+ output,
74
+ message: "Select provider to remove",
75
+ items: removableProviders.map((provider) => ({
76
+ label: `${provider.label} (${provider.id})${formatSources(provider.sources)}`,
77
+ value: provider.id,
78
+ })),
79
+ });
80
+ }
81
+
82
+ function removeProviderCredential({ authStorage, id }) {
83
+ const existed = safeHasAuthProvider(authStorage, id);
84
+ if (typeof authStorage.remove === "function") authStorage.remove(id);
85
+ return existed;
86
+ }
87
+
88
+ function safeListAuthProviders(authStorage) {
89
+ if (typeof authStorage.list !== "function") return [];
90
+ try {
91
+ const providers = authStorage.list();
92
+ return Array.isArray(providers) ? providers.filter((id) => typeof id === "string" && id) : [];
93
+ } catch {
94
+ return [];
95
+ }
96
+ }
97
+
98
+ function safeHasAuthProvider(authStorage, id) {
99
+ if (typeof authStorage.get === "function") {
100
+ try {
101
+ return authStorage.get(id) != null;
102
+ } catch {
103
+ return false;
104
+ }
105
+ }
106
+ return safeListAuthProviders(authStorage).includes(id);
107
+ }
108
+
109
+ function formatSources(sources) {
110
+ if (!sources.length) return "";
111
+ return ` — ${sources.join(" + ")}`;
112
+ }
113
+
114
+ async function confirmRemoval({ input, output, provider }) {
115
+ const answer = String(await readLine({
116
+ input,
117
+ output,
118
+ prompt: `Remove provider "${provider.label}" (${provider.id})? This deletes local config and credentials. [y/N] `,
119
+ }) ?? "").trim().toLowerCase();
120
+ return answer === "y" || answer === "yes";
121
+ }
122
+
123
+ function readLine({ input = process.stdin, output = process.stdout, prompt }) {
124
+ const rl = createInterface({ input, output });
125
+ return new Promise((resolve) => rl.question(prompt, (answer) => {
126
+ rl.close();
127
+ resolve(answer);
128
+ }));
129
+ }
@@ -0,0 +1,149 @@
1
+ import { existsSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync, closeSync } from "node:fs";
2
+ import { randomUUID, createHash } from "node:crypto";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { getMarchSessionStateDir, normalizeSessionId } from "../state/march-session-state.mjs";
5
+
6
+ export const DEFAULT_SESSION_CONTROLLER_LEASE_TTL_MS = 30_000;
7
+ export const DEFAULT_SESSION_CONTROLLER_HEARTBEAT_MS = 5_000;
8
+
9
+ export class SessionControllerLeaseConflictError extends Error {
10
+ constructor({ sessionId, owner }) {
11
+ super(formatControllerLeaseConflict({ sessionId, owner }));
12
+ this.name = "SessionControllerLeaseConflictError";
13
+ this.code = "SESSION_CONTROLLER_LEASE_CONFLICT";
14
+ this.sessionId = sessionId;
15
+ this.owner = owner;
16
+ }
17
+ }
18
+
19
+ export function createSessionControllerLeaseManager({
20
+ instanceId = randomUUID(),
21
+ pid = process.pid,
22
+ cwd = process.cwd(),
23
+ now = () => Date.now(),
24
+ ttlMs = DEFAULT_SESSION_CONTROLLER_LEASE_TTL_MS,
25
+ heartbeatMs = DEFAULT_SESSION_CONTROLLER_HEARTBEAT_MS,
26
+ } = {}) {
27
+ const ownerBase = { instanceId, pid, cwd: resolve(cwd) };
28
+
29
+ return {
30
+ instanceId,
31
+ acquire(session, options = {}) {
32
+ const target = resolveControllerLeaseTarget(session);
33
+ const path = getSessionControllerLeasePath(target);
34
+ const lease = writeLease({ path, target, ownerBase, now, ttlMs, force: Boolean(options.force) });
35
+ const heartbeat = heartbeatMs > 0 ? setInterval(() => {
36
+ try { refreshLease({ path, lease, now, ttlMs }); } catch {}
37
+ }, heartbeatMs) : null;
38
+ heartbeat?.unref?.();
39
+ return {
40
+ ...lease,
41
+ path,
42
+ target,
43
+ assertOwned() {
44
+ const current = readLease(path);
45
+ if (!current || current.owner?.instanceId !== lease.owner.instanceId || current.token !== lease.token || isExpired(current, now())) {
46
+ throw new SessionControllerLeaseConflictError({ sessionId: target.sessionId, owner: current?.owner ?? null });
47
+ }
48
+ },
49
+ release() {
50
+ if (heartbeat) clearInterval(heartbeat);
51
+ releaseLease({ path, lease });
52
+ },
53
+ };
54
+ },
55
+ };
56
+ }
57
+
58
+ export function getSessionControllerLeasePath({ sessionId, sessionPath = null, projectMarchDir = null }) {
59
+ if (sessionPath) {
60
+ const identity = resolve(sessionPath);
61
+ const key = createHash("sha256").update(identity).digest("hex").slice(0, 32);
62
+ return join(dirname(identity), ".march-controller-leases", `${key}.json`);
63
+ }
64
+ if (!projectMarchDir || !sessionId) throw new Error("session controller lease requires a session path or project March dir plus session id");
65
+ return join(getMarchSessionStateDir(projectMarchDir, sessionId), "controller-lease.json");
66
+ }
67
+
68
+ export function resolveControllerLeaseTarget({ sessionId, sessionPath = null, projectMarchDir = null }) {
69
+ const id = normalizeSessionId(sessionId);
70
+ return {
71
+ sessionId: id,
72
+ sessionPath: sessionPath ? resolve(sessionPath) : null,
73
+ projectMarchDir: projectMarchDir ? resolve(projectMarchDir) : null,
74
+ };
75
+ }
76
+
77
+ function writeLease({ path, target, ownerBase, now, ttlMs, force }) {
78
+ mkdirSync(dirname(path), { recursive: true });
79
+ const current = readLease(path);
80
+ if (current && !force && !isExpired(current, now()) && current.owner?.instanceId !== ownerBase.instanceId) {
81
+ throw new SessionControllerLeaseConflictError({ sessionId: target.sessionId, owner: current.owner });
82
+ }
83
+ const lease = createLease({ target, ownerBase, now, ttlMs });
84
+ if (force || current) {
85
+ writeFileSync(path, JSON.stringify(lease, null, 2), "utf8");
86
+ return lease;
87
+ }
88
+ let fd = null;
89
+ try {
90
+ fd = openSync(path, "wx");
91
+ writeFileSync(fd, JSON.stringify(lease, null, 2), "utf8");
92
+ return lease;
93
+ } catch {
94
+ const raced = readLease(path);
95
+ if (raced && !isExpired(raced, now())) throw new SessionControllerLeaseConflictError({ sessionId: target.sessionId, owner: raced.owner });
96
+ try { unlinkSync(path); } catch {}
97
+ writeFileSync(path, JSON.stringify(lease, null, 2), "utf8");
98
+ return lease;
99
+ } finally {
100
+ if (fd !== null) closeSync(fd);
101
+ }
102
+ }
103
+
104
+ function createLease({ target, ownerBase, now, ttlMs }) {
105
+ const acquiredAtMs = now();
106
+ return {
107
+ version: 1,
108
+ token: randomUUID(),
109
+ sessionId: target.sessionId,
110
+ sessionPath: target.sessionPath,
111
+ owner: ownerBase,
112
+ acquiredAt: new Date(acquiredAtMs).toISOString(),
113
+ heartbeatAt: new Date(acquiredAtMs).toISOString(),
114
+ expiresAt: new Date(acquiredAtMs + ttlMs).toISOString(),
115
+ };
116
+ }
117
+
118
+ function refreshLease({ path, lease, now, ttlMs }) {
119
+ const current = readLease(path);
120
+ if (!current || current.token !== lease.token || current.owner?.instanceId !== lease.owner.instanceId) return;
121
+ const heartbeatAtMs = now();
122
+ writeFileSync(path, JSON.stringify({
123
+ ...current,
124
+ heartbeatAt: new Date(heartbeatAtMs).toISOString(),
125
+ expiresAt: new Date(heartbeatAtMs + ttlMs).toISOString(),
126
+ }, null, 2), "utf8");
127
+ }
128
+
129
+ function releaseLease({ path, lease }) {
130
+ const current = readLease(path);
131
+ if (!current || current.token !== lease.token || current.owner?.instanceId !== lease.owner.instanceId) return;
132
+ try { unlinkSync(path); } catch {}
133
+ }
134
+
135
+ function readLease(path) {
136
+ if (!existsSync(path)) return null;
137
+ try { return JSON.parse(readFileSync(path, "utf8")); } catch { return null; }
138
+ }
139
+
140
+ function isExpired(lease, nowMs) {
141
+ return Date.parse(lease.expiresAt ?? 0) <= nowMs;
142
+ }
143
+
144
+ function formatControllerLeaseConflict({ sessionId, owner }) {
145
+ const parts = [`Session "${sessionId}" is already controlled by another March instance.`];
146
+ if (owner?.cwd) parts.push(`cwd: ${owner.cwd}`);
147
+ if (owner?.pid) parts.push(`pid: ${owner.pid}`);
148
+ return parts.join(" ");
149
+ }
@@ -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,17 +90,23 @@ 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
 
@@ -97,18 +114,40 @@ export function createWorkspaceSessionSupervisor({ initialRuntime, createProject
97
114
  if (!runtime) runtime = await createProjectRuntime(project);
98
115
 
99
116
  const targetSessionId = session?.id ?? null;
100
- let restoreState = null;
101
- if (session?.path && getRuntimeSessionId(runtime) !== targetSessionId) {
102
- restoreState = loadWorkspaceMarchSessionState({ runtime, session });
103
- await runtime.runner.switchPiSession(session.path, restoreState);
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
  }
105
- if (targetSessionId) syncSessionState(runtime, targetSessionId);
139
+ }
106
140
 
107
- active = runtime;
108
- rememberRuntime(runtime);
109
- mirrorSessionState(viewSessionState, runtime.sessionState);
110
- onActivate?.({ projectId: runtime.project.projectId, sessionId: getRuntimeSessionId(runtime), runtime, restoreState });
111
- 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;
112
151
  }
113
152
 
114
153
  async function getIdleRuntimeForProject(project) {
@@ -138,29 +177,100 @@ export function createWorkspaceSessionSupervisor({ initialRuntime, createProject
138
177
  disposed = true;
139
178
  const uniqueRuntimes = new Set(runtimes.values());
140
179
  await Promise.all(Array.from(uniqueRuntimes, async (runtime) => {
180
+ releaseRuntimeLease(runtime);
141
181
  await runtime.runner.dispose?.();
142
182
  runtime.memoryStore?.close?.();
143
183
  }));
144
184
  }
145
- }
146
185
 
147
- function loadWorkspaceMarchSessionState({ runtime, session }) {
148
- const stored = loadMarchSessionStateForPiBackend({
149
- projectMarchDir: runtime.projectMarchDir,
150
- sessionId: session.id,
151
- sessionRef: session.path,
152
- });
153
- if (!stored) throw new Error(`March session state not found for ${session.id}; refusing partial resume`);
154
- if (stored.state.cwd && stored.state.cwd !== runtime.runner.engine.cwd) {
155
- 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);
156
263
  }
157
- return stored.state;
158
264
  }
159
265
 
160
266
  function getRuntimeSessionId(runtime) {
161
267
  return runtime.sessionState?.sessionId ?? runtime.runner.getSessionStats?.()?.sessionId ?? null;
162
268
  }
163
269
 
270
+ function getRuntimeSessionFile(runtime) {
271
+ return runtime.runner.getSessionStats?.()?.sessionFile ?? null;
272
+ }
273
+
164
274
  function syncSessionState(runtime, sessionId) {
165
275
  if (!runtime.sessionState || !sessionId) return;
166
276
  runtime.sessionState.sessionId = 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
+ }