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 +1 -1
- package/src/agent/runner.mjs +2 -1
- package/src/agent/turn/turn-events.mjs +6 -0
- package/src/agent/turn/turn-runner.mjs +87 -7
- package/src/cli/args.mjs +1 -0
- package/src/cli/repl-loop.mjs +4 -0
- package/src/cli/startup/app-runtime.mjs +4 -1
- package/src/cli/workspace/command.mjs +44 -0
- package/src/config/config-json.mjs +20 -0
- package/src/provider/command.mjs +5 -1
- package/src/provider/remove-command.mjs +129 -0
- package/src/session/control/controller-lease.mjs +149 -0
- package/src/workspace/session-restore.mjs +22 -0
- package/src/workspace/supervisor.mjs +148 -34
- package/src/workspace/view-runtime.mjs +40 -0
package/package.json
CHANGED
package/src/agent/runner.mjs
CHANGED
|
@@ -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")
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (event.type === "
|
|
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
|
-
|
|
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
|
package/src/cli/repl-loop.mjs
CHANGED
|
@@ -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 }) =>
|
|
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)
|
package/src/provider/command.mjs
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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)
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
139
|
+
}
|
|
106
140
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
+
}
|