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.
- package/package.json +1 -1
- package/src/agent/runner/runner-utils.mjs +20 -0
- package/src/agent/runner.mjs +16 -17
- package/src/agent/runtime/remote-ui-client.mjs +0 -1
- package/src/agent/runtime/runner-process-client.mjs +2 -0
- package/src/agent/runtime/runner-process-factory.mjs +3 -4
- package/src/agent/runtime/runner-runtime-host.mjs +0 -2
- package/src/agent/runtime/ui-event-bridge.mjs +0 -2
- package/src/agent/session/session-options.mjs +1 -2
- package/src/agent/tools.mjs +2 -23
- package/src/agent/turn/turn-runner.mjs +4 -4
- package/src/cli/args.mjs +3 -3
- package/src/cli/commands/mode-command.mjs +1 -0
- package/src/cli/commands/registry/slash-command-registry.mjs +4 -3
- package/src/cli/fallback-ui.mjs +0 -2
- package/src/cli/input/mode-state.mjs +1 -1
- package/src/cli/repl-commands.mjs +1 -1
- package/src/cli/repl-loop.mjs +67 -19
- package/src/cli/session/pi-session-switch-command.mjs +11 -11
- package/src/cli/session/session-list-command.mjs +1 -1
- package/src/cli/session/session-source-command.mjs +0 -76
- package/src/cli/startup/app-runtime.mjs +103 -4
- package/src/cli/startup/create-runtime-runner.mjs +2 -1
- package/src/cli/startup/startup-session.mjs +3 -13
- package/src/cli/ui.mjs +0 -6
- package/src/cli/workspace/command.mjs +121 -0
- package/src/cli/workspace/output-router.mjs +127 -0
- package/src/cli/workspace/project-runtime.mjs +94 -0
- package/src/cli/workspace/runtime-session-state.mjs +9 -0
- package/src/config/features.mjs +0 -1
- package/src/extensions/lifecycle-adapter.mjs +3 -3
- package/src/main.mjs +11 -1
- package/src/notification/desktop-notifier.mjs +16 -8
- package/src/session/sidecar-sync.mjs +3 -17
- package/src/session/sidecar.mjs +40 -41
- package/src/session/state/march-session-state.mjs +175 -0
- package/src/session/state/march-session-sync.mjs +20 -0
- package/src/session/state/march-session-ui-state.mjs +60 -0
- package/src/web-ui/dist/assets/index-BQtl1uQs.css +1 -0
- package/src/web-ui/dist/assets/index-DrlJis_D.js +1845 -0
- package/src/web-ui/dist/index.html +13 -0
- package/src/web-ui/runtime-host.mjs +1 -2
- package/src/web-ui/src/components/timeline/TimelineBlocks.tsx +2 -10
- package/src/web-ui/src/mockData.ts +1 -8
- package/src/web-ui/src/model.ts +0 -2
- package/src/web-ui/src/runtime/client.ts +0 -1
- package/src/web-ui/src/runtime/runtimeTimeline.ts +1 -3
- package/src/web-ui/src/styles/shell.css +1 -2
- package/src/web-ui/src/timelineAdapter.ts +1 -2
- package/src/workspace/project-id.mjs +14 -0
- package/src/workspace/project-registry.mjs +74 -0
- package/src/workspace/session-index.mjs +102 -0
- package/src/workspace/supervisor.mjs +178 -0
- package/src/agent/pi-session/pi-session-sidecar-failure.mjs +0 -10
- package/src/cli/permissions.mjs +0 -103
- package/src/cli/session/session-switch-command.mjs +0 -1
- package/src/cli/tui/permission-request-ui.mjs +0 -18
- package/src/session/persist.mjs +0 -1
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
6
|
+
<title>March Web</title>
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-DrlJis_D.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BQtl1uQs.css">
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<div id="root"></div>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -55,7 +55,6 @@ export async function createWebRuntimeHost({ args, config, cwd, stateRoot } = {}
|
|
|
55
55
|
namespace,
|
|
56
56
|
projectMarchDir,
|
|
57
57
|
extensionPaths,
|
|
58
|
-
permissionMode: args.permissionMode,
|
|
59
58
|
shellRuntime: Boolean(shellRuntime),
|
|
60
59
|
lifecycleHooks: lifecycleManifests.hooks,
|
|
61
60
|
lifecycleDiagnostics: lifecycleManifests.diagnostics,
|
|
@@ -108,7 +107,7 @@ export function createHeadlessWebUi() {
|
|
|
108
107
|
providerQuotaSnapshot: () => {},
|
|
109
108
|
clearOutput: () => {}, restoreTranscript: () => {}, setStatusBar: () => {},
|
|
110
109
|
turnStart: () => {}, turnEnd: () => {}, retryStart: () => {}, retryEnd: () => {},
|
|
111
|
-
editDiff: () => {},
|
|
110
|
+
editDiff: () => {},
|
|
112
111
|
setEscapeHandler: () => {}, setCtrlCHandler: () => {}, setShiftTabHandler: () => {},
|
|
113
112
|
setCtrlTHandler: () => {}, setCtrlLHandler: () => {}, setPasteImageHandler: () => {},
|
|
114
113
|
getInputText: () => "", insertTextAtCursor: () => {}, openExternalEditor: () => {},
|
|
@@ -39,8 +39,7 @@ function AuxBlock({ item }: { item: Exclude<TimelineItem, { kind: "message" }> }
|
|
|
39
39
|
return <DiffBlock item={item} />;
|
|
40
40
|
case "terminal":
|
|
41
41
|
return <TerminalBlock item={item} />;
|
|
42
|
-
|
|
43
|
-
return <PermissionBlock item={item} />;
|
|
42
|
+
|
|
44
43
|
case "error":
|
|
45
44
|
return <ErrorBlock item={item} />;
|
|
46
45
|
}
|
|
@@ -90,14 +89,7 @@ function TerminalBlock({ item }: { item: Extract<TimelineItem, { kind: "terminal
|
|
|
90
89
|
);
|
|
91
90
|
}
|
|
92
91
|
|
|
93
|
-
|
|
94
|
-
return (
|
|
95
|
-
<div className={`timeline-aux permission-block ${item.status}`}>
|
|
96
|
-
<div className="aux-title"><span>permission</span><strong>{item.title}</strong><em>{item.status}</em></div>
|
|
97
|
-
<p>{item.detail}</p>
|
|
98
|
-
</div>
|
|
99
|
-
);
|
|
100
|
-
}
|
|
92
|
+
|
|
101
93
|
|
|
102
94
|
function ErrorBlock({ item }: { item: Extract<TimelineItem, { kind: "error" }> }) {
|
|
103
95
|
return (
|
|
@@ -61,14 +61,7 @@ export const mockWebUiModel: WebUiModel = {
|
|
|
61
61
|
command: "npm run test:fast",
|
|
62
62
|
output: "PASS web-ui.smoke.mjs",
|
|
63
63
|
status: "done",
|
|
64
|
-
}
|
|
65
|
-
{
|
|
66
|
-
id: "perm1",
|
|
67
|
-
type: "permission_request",
|
|
68
|
-
title: "Write files",
|
|
69
|
-
detail: "Edit local workspace source files",
|
|
70
|
-
status: "approved",
|
|
71
|
-
},
|
|
64
|
+
}
|
|
72
65
|
],
|
|
73
66
|
},
|
|
74
67
|
sessions: [
|
package/src/web-ui/src/model.ts
CHANGED
|
@@ -17,7 +17,6 @@ export type MarchTimelineEvent =
|
|
|
17
17
|
| { id: string; type: "tool_result"; tool: string; summary: string; status: "done" | "failed" }
|
|
18
18
|
| { id: string; type: "file_diff"; path: string; lines: Array<{ kind: "add" | "remove" | "keep"; text: string }> }
|
|
19
19
|
| { id: string; type: "terminal_output"; command: string; output: string; status: "running" | "done" | "failed" }
|
|
20
|
-
| { id: string; type: "permission_request"; title: string; detail: string; status: "pending" | "approved" | "denied" }
|
|
21
20
|
| { id: string; type: "error"; message: string; detail?: string };
|
|
22
21
|
|
|
23
22
|
export type TimelineItem =
|
|
@@ -26,7 +25,6 @@ export type TimelineItem =
|
|
|
26
25
|
| { id: string; kind: "tool"; tool: string; target: string; status: "running" | "done" | "failed"; summary?: string }
|
|
27
26
|
| { id: string; kind: "diff"; path: string; lines: Array<{ kind: "add" | "remove" | "keep"; text: string }> }
|
|
28
27
|
| { id: string; kind: "terminal"; command: string; output: string; status: "running" | "done" | "failed" }
|
|
29
|
-
| { id: string; kind: "permission"; title: string; detail: string; status: "pending" | "approved" | "denied" }
|
|
30
28
|
| { id: string; kind: "error"; message: string; detail?: string };
|
|
31
29
|
|
|
32
30
|
export type SessionSummary = {
|
|
@@ -12,7 +12,6 @@ export type RuntimeUiEvent =
|
|
|
12
12
|
| { type: "tool_start"; name: string; args?: unknown }
|
|
13
13
|
| { type: "tool_end"; name: string; isError?: boolean; result?: unknown }
|
|
14
14
|
| { type: "edit_diff"; path: string; diffLines?: Array<{ type?: string; text?: string }> }
|
|
15
|
-
| { type: "permission_request"; toolName: string; category?: string; params?: unknown }
|
|
16
15
|
| { type: "provider_quota_snapshot"; snapshot: ProviderQuotaSnapshot | null }
|
|
17
16
|
| { type: "status"; text: string }
|
|
18
17
|
| { type: "retry_start"; errorMessage?: string }
|
|
@@ -26,9 +26,7 @@ export function applyRuntimeEvent(events: MarchTimelineEvent[], event: RuntimeUi
|
|
|
26
26
|
case "edit_diff":
|
|
27
27
|
next.push({ id, type: "file_diff", path: event.path, lines: toDiffLines(event.diffLines) });
|
|
28
28
|
return next;
|
|
29
|
-
|
|
30
|
-
next.push({ id, type: "permission_request", title: event.toolName, detail: event.category ?? "permission", status: "pending" });
|
|
31
|
-
return next;
|
|
29
|
+
|
|
32
30
|
case "status":
|
|
33
31
|
next.push({ id, type: "terminal_output", command: "status", output: event.text, status: "done" });
|
|
34
32
|
return next;
|
|
@@ -95,8 +95,7 @@
|
|
|
95
95
|
.diff-line.remove, .error-block strong { color: var(--color-danger); }
|
|
96
96
|
.diff-line.keep { color: var(--color-text-muted); }
|
|
97
97
|
.terminal-block pre { margin: 7px 0 0; color: var(--color-text-muted); font-size: 12px; white-space: pre-wrap; }
|
|
98
|
-
|
|
99
|
-
.permission-block.approved strong { color: var(--color-success); }
|
|
98
|
+
|
|
100
99
|
|
|
101
100
|
.right-body { flex: 1; min-height: 0; overflow: auto; padding: 12px; }
|
|
102
101
|
.workspace-picker { display: grid; gap: 7px; padding-bottom: 10px; }
|
|
@@ -35,8 +35,7 @@ function toTimelineItem(event: MarchTimelineEvent): TimelineItem {
|
|
|
35
35
|
return { id: event.id, kind: "diff", path: event.path, lines: event.lines };
|
|
36
36
|
case "terminal_output":
|
|
37
37
|
return { id: event.id, kind: "terminal", command: event.command, output: event.output, status: event.status };
|
|
38
|
-
|
|
39
|
-
return { id: event.id, kind: "permission", title: event.title, detail: event.detail, status: event.status };
|
|
38
|
+
|
|
40
39
|
case "error":
|
|
41
40
|
return { id: event.id, kind: "error", message: event.message, detail: event.detail };
|
|
42
41
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
export function loadOrCreateProjectId(projectMarchDir) {
|
|
6
|
+
if (!existsSync(projectMarchDir)) mkdirSync(projectMarchDir, { recursive: true });
|
|
7
|
+
const idFile = resolve(projectMarchDir, "project-id");
|
|
8
|
+
if (existsSync(idFile)) {
|
|
9
|
+
return readFileSync(idFile, "utf8").trim();
|
|
10
|
+
}
|
|
11
|
+
const id = randomUUID();
|
|
12
|
+
writeFileSync(idFile, id, "utf8");
|
|
13
|
+
return id;
|
|
14
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
3
|
+
import { loadOrCreateProjectId } from "./project-id.mjs";
|
|
4
|
+
|
|
5
|
+
const REGISTRY_VERSION = 1;
|
|
6
|
+
|
|
7
|
+
export function workspaceRegistryPath(stateRoot) {
|
|
8
|
+
return join(stateRoot, "workspaces", "projects.json");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function loadProjectRegistry({ stateRoot }) {
|
|
12
|
+
const path = workspaceRegistryPath(stateRoot);
|
|
13
|
+
if (!existsSync(path)) return { version: REGISTRY_VERSION, projects: [] };
|
|
14
|
+
return normalizeRegistry(JSON.parse(readFileSync(path, "utf8")));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function saveProjectRegistry({ stateRoot, registry }) {
|
|
18
|
+
const path = workspaceRegistryPath(stateRoot);
|
|
19
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
20
|
+
writeFileSync(path, `${JSON.stringify(normalizeRegistry(registry), null, 2)}\n`, "utf8");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function registerProject({ stateRoot, rootPath, now = new Date() }) {
|
|
24
|
+
const normalizedRoot = resolve(rootPath);
|
|
25
|
+
const projectMarchDir = resolve(normalizedRoot, ".march");
|
|
26
|
+
const projectId = loadOrCreateProjectId(projectMarchDir);
|
|
27
|
+
const registry = loadProjectRegistry({ stateRoot });
|
|
28
|
+
const project = normalizeProject({
|
|
29
|
+
projectId,
|
|
30
|
+
rootPath: normalizedRoot,
|
|
31
|
+
displayName: basename(normalizedRoot) || normalizedRoot,
|
|
32
|
+
lastOpenedAt: now.toISOString(),
|
|
33
|
+
});
|
|
34
|
+
const index = registry.projects.findIndex((entry) => entry.projectId === projectId || samePath(entry.rootPath, normalizedRoot));
|
|
35
|
+
if (index >= 0) registry.projects[index] = { ...registry.projects[index], ...project };
|
|
36
|
+
else registry.projects.push(project);
|
|
37
|
+
registry.projects.sort(compareProjectsByLastOpened);
|
|
38
|
+
saveProjectRegistry({ stateRoot, registry });
|
|
39
|
+
return project;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function listRegisteredProjects({ stateRoot }) {
|
|
43
|
+
return loadProjectRegistry({ stateRoot }).projects.slice().sort(compareProjectsByLastOpened);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function findRegisteredProject({ stateRoot, projectId }) {
|
|
47
|
+
return listRegisteredProjects({ stateRoot }).find((project) => project.projectId === projectId) ?? null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function normalizeRegistry(value) {
|
|
51
|
+
return {
|
|
52
|
+
version: Number(value?.version) || REGISTRY_VERSION,
|
|
53
|
+
projects: Array.isArray(value?.projects) ? value.projects.map(normalizeProject).filter(Boolean) : [],
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function normalizeProject(value) {
|
|
58
|
+
if (!value?.projectId || !value?.rootPath) return null;
|
|
59
|
+
const rootPath = resolve(String(value.rootPath));
|
|
60
|
+
return {
|
|
61
|
+
projectId: String(value.projectId),
|
|
62
|
+
rootPath,
|
|
63
|
+
displayName: String(value.displayName || basename(rootPath) || rootPath),
|
|
64
|
+
lastOpenedAt: String(value.lastOpenedAt || ""),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function compareProjectsByLastOpened(a, b) {
|
|
69
|
+
return String(b.lastOpenedAt || "").localeCompare(String(a.lastOpenedAt || ""));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function samePath(a, b) {
|
|
73
|
+
return resolve(String(a)).toLowerCase() === resolve(String(b)).toLowerCase();
|
|
74
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { listPiSessionInfos } from "../session/pi-manager.mjs";
|
|
3
|
+
import { listMarchSessionStates } from "../session/state/march-session-state.mjs";
|
|
4
|
+
import { listRegisteredProjects } from "./project-registry.mjs";
|
|
5
|
+
|
|
6
|
+
export async function listWorkspaceSessions({ stateRoot, currentProjectId = null, listSessions = listPiSessionInfos }) {
|
|
7
|
+
const projects = listRegisteredProjects({ stateRoot });
|
|
8
|
+
const entries = [];
|
|
9
|
+
for (const project of projects) {
|
|
10
|
+
const projectMarchDir = resolve(project.rootPath, ".march");
|
|
11
|
+
let sessions = [];
|
|
12
|
+
try {
|
|
13
|
+
sessions = await listSessions({ cwd: project.rootPath, projectMarchDir });
|
|
14
|
+
sessions = mergeMarchSessionStates({ projectMarchDir, backendSessions: sessions });
|
|
15
|
+
} catch {
|
|
16
|
+
sessions = [];
|
|
17
|
+
}
|
|
18
|
+
entries.push({
|
|
19
|
+
...project,
|
|
20
|
+
current: project.projectId === currentProjectId,
|
|
21
|
+
sessions,
|
|
22
|
+
sessionCount: sessions.length,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
return entries;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function buildWorkspaceSessionSelectItems(projects, currentSessionId = null) {
|
|
29
|
+
const items = [];
|
|
30
|
+
for (const project of projects) {
|
|
31
|
+
if (project.sessions.length === 0) {
|
|
32
|
+
items.push({
|
|
33
|
+
value: `${project.projectId}:new`,
|
|
34
|
+
label: `${project.displayName} / + new session`,
|
|
35
|
+
description: project.current ? "current project" : project.rootPath,
|
|
36
|
+
project,
|
|
37
|
+
session: null,
|
|
38
|
+
kind: "new-session",
|
|
39
|
+
});
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
for (const session of project.sessions) {
|
|
43
|
+
const current = project.current && session.id === currentSessionId;
|
|
44
|
+
items.push({
|
|
45
|
+
value: `${project.projectId}:${session.id}`,
|
|
46
|
+
label: `${project.displayName} / ${session.name || session.firstMessage || session.id}`,
|
|
47
|
+
description: `${current ? "current · " : ""}${formatWorkspaceSessionTime(session.savedAt)} · ${project.rootPath}`,
|
|
48
|
+
project,
|
|
49
|
+
session,
|
|
50
|
+
kind: "session",
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return items.sort(compareWorkspaceItems);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function workspaceSessionSearchText(item) {
|
|
58
|
+
const session = item?.session;
|
|
59
|
+
const project = item?.project;
|
|
60
|
+
return [item?.label, item?.description, project?.displayName, project?.rootPath, session?.id, session?.name, session?.firstMessage]
|
|
61
|
+
.filter(Boolean)
|
|
62
|
+
.join(" ");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function compareWorkspaceItems(a, b) {
|
|
66
|
+
const aCurrent = a.project?.current ? 1 : 0;
|
|
67
|
+
const bCurrent = b.project?.current ? 1 : 0;
|
|
68
|
+
if (aCurrent !== bCurrent) return bCurrent - aCurrent;
|
|
69
|
+
const aTime = a.session?.savedAt || a.project?.lastOpenedAt || "";
|
|
70
|
+
const bTime = b.session?.savedAt || b.project?.lastOpenedAt || "";
|
|
71
|
+
return String(bTime).localeCompare(String(aTime));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function formatWorkspaceSessionTime(value) {
|
|
75
|
+
if (!value) return "no saved time";
|
|
76
|
+
return String(value).slice(0, 16).replace("T", " ");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function mergeMarchSessionStates({ projectMarchDir, backendSessions }) {
|
|
80
|
+
const backendById = new Map(backendSessions.map((session) => [session.id, session]));
|
|
81
|
+
const backendByPath = new Map(backendSessions.map((session) => [session.path, session]));
|
|
82
|
+
const marchSessions = listMarchSessionStates({ projectMarchDir }).map(({ state }) => {
|
|
83
|
+
const backend = state.backend?.type === "pi"
|
|
84
|
+
? backendById.get(state.backend.sessionId) ?? backendByPath.get(state.backend.sessionFile)
|
|
85
|
+
: null;
|
|
86
|
+
return {
|
|
87
|
+
id: state.sessionId,
|
|
88
|
+
path: state.backend?.sessionFile ?? backend?.path ?? null,
|
|
89
|
+
savedAt: state.savedAt,
|
|
90
|
+
createdAt: backend?.createdAt ?? "",
|
|
91
|
+
cwd: state.cwd,
|
|
92
|
+
name: state.sessionName || backend?.name || "",
|
|
93
|
+
turnCount: state.turns?.length ?? backend?.turnCount ?? 0,
|
|
94
|
+
firstMessage: state.turns?.[0]?.userMessage ?? backend?.firstMessage ?? "",
|
|
95
|
+
parentSessionPath: backend?.parentSessionPath ?? null,
|
|
96
|
+
backend,
|
|
97
|
+
};
|
|
98
|
+
});
|
|
99
|
+
const seenBackendIds = new Set(marchSessions.map((session) => session.backend?.id).filter(Boolean));
|
|
100
|
+
const legacyBackendSessions = backendSessions.filter((session) => !seenBackendIds.has(session.id));
|
|
101
|
+
return [...marchSessions, ...legacyBackendSessions];
|
|
102
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { loadMarchSessionStateForPiBackend } from "../session/state/march-session-state.mjs";
|
|
3
|
+
|
|
4
|
+
export function createWorkspaceSessionSupervisor({ initialRuntime, createProjectRuntime, viewSessionState = initialRuntime?.sessionState, onActivate = null }) {
|
|
5
|
+
if (!initialRuntime?.project?.projectId) throw new Error("initial workspace runtime is missing project metadata");
|
|
6
|
+
if (typeof createProjectRuntime !== "function") throw new Error("createProjectRuntime is required");
|
|
7
|
+
|
|
8
|
+
const runtimes = new Map();
|
|
9
|
+
let active = initialRuntime;
|
|
10
|
+
let disposed = false;
|
|
11
|
+
rememberRuntime(initialRuntime);
|
|
12
|
+
|
|
13
|
+
const runner = new Proxy({}, {
|
|
14
|
+
get(_target, prop) {
|
|
15
|
+
if (prop === "dispose") return dispose;
|
|
16
|
+
if (prop === "getActiveWorkspaceRuntime") return getActive;
|
|
17
|
+
if (prop === "activateWorkspaceSession") return activateWorkspaceSession;
|
|
18
|
+
if (prop === "activateWorkspaceSessionById") return activateWorkspaceSessionById;
|
|
19
|
+
if (prop === "startNewWorkspaceSession") return startNewWorkspaceSession;
|
|
20
|
+
if (prop === "refreshActiveRuntime") return refreshActiveRuntime;
|
|
21
|
+
const value = active.runner[prop];
|
|
22
|
+
return typeof value === "function" ? value.bind(active.runner) : value;
|
|
23
|
+
},
|
|
24
|
+
set(_target, prop, value) {
|
|
25
|
+
active.runner[prop] = value;
|
|
26
|
+
return true;
|
|
27
|
+
},
|
|
28
|
+
has(_target, prop) {
|
|
29
|
+
return prop === "dispose" || prop === "getActiveWorkspaceRuntime" || prop === "activateWorkspaceSession" || prop === "activateWorkspaceSessionById" || prop === "startNewWorkspaceSession" || prop === "refreshActiveRuntime" || prop in active.runner;
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
runner,
|
|
35
|
+
getActive,
|
|
36
|
+
hasRunningTurn,
|
|
37
|
+
getRunningTurns,
|
|
38
|
+
getRuntimeSummaries,
|
|
39
|
+
refreshActiveRuntime,
|
|
40
|
+
activateWorkspaceSession,
|
|
41
|
+
activateWorkspaceSessionById,
|
|
42
|
+
startNewWorkspaceSession,
|
|
43
|
+
dispose,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function getActive() {
|
|
47
|
+
return active;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function hasRunningTurn() {
|
|
51
|
+
return getRunningTurns().length > 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function getRunningTurns() {
|
|
55
|
+
return Array.from(runtimes.values()).filter((runtime) => runtime.turnTask);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function getRuntimeSummaries() {
|
|
59
|
+
return Array.from(runtimes.values()).map((runtime) => ({
|
|
60
|
+
projectId: runtime.project.projectId,
|
|
61
|
+
sessionId: getRuntimeSessionId(runtime),
|
|
62
|
+
running: Boolean(runtime.turnTask),
|
|
63
|
+
active: runtime === active,
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function refreshActiveRuntime() {
|
|
68
|
+
rememberRuntime(active);
|
|
69
|
+
mirrorSessionState(viewSessionState, active.sessionState);
|
|
70
|
+
onActivate?.({ projectId: active.project.projectId, sessionId: getRuntimeSessionId(active), runtime: active });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function activateWorkspaceSessionById({ projects = [], projectId, sessionId }) {
|
|
74
|
+
const project = projects.find((candidate) => candidate.projectId === projectId);
|
|
75
|
+
if (!project) throw new Error(`workspace project not found: ${projectId}`);
|
|
76
|
+
const session = project.sessions?.find((candidate) => candidate.id === sessionId) ?? null;
|
|
77
|
+
if (sessionId && !session) throw new Error(`workspace session not found: ${sessionId}`);
|
|
78
|
+
return await activateWorkspaceSession({ project, session });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function startNewWorkspaceSession(project) {
|
|
82
|
+
const runtime = await getIdleRuntimeForProject(project);
|
|
83
|
+
active = runtime;
|
|
84
|
+
const result = await active.runner.startNewSession();
|
|
85
|
+
if (!result?.cancelled && result?.sessionId) syncSessionState(active, result.sessionId);
|
|
86
|
+
rememberRuntime(active);
|
|
87
|
+
mirrorSessionState(viewSessionState, active.sessionState);
|
|
88
|
+
onActivate?.({ projectId: active.project.projectId, sessionId: getRuntimeSessionId(active), runtime: active, restoreState: null });
|
|
89
|
+
return { runtime: active, result };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function activateWorkspaceSession({ project, session = null }) {
|
|
93
|
+
if (disposed) throw new Error("workspace supervisor is already disposed");
|
|
94
|
+
if (!project?.projectId) throw new Error("workspace project is required");
|
|
95
|
+
|
|
96
|
+
let runtime = session?.id ? runtimes.get(runtimeKey(project.projectId, session.id)) : findIdleRuntime(project.projectId);
|
|
97
|
+
if (!runtime) runtime = await createProjectRuntime(project);
|
|
98
|
+
|
|
99
|
+
let restoreState = null;
|
|
100
|
+
if (session?.path && getRuntimeSessionId(runtime) !== session.id) {
|
|
101
|
+
restoreState = loadWorkspaceMarchSessionState({ runtime, session });
|
|
102
|
+
await runtime.runner.switchPiSession(session.path, restoreState);
|
|
103
|
+
syncSessionState(runtime, session.id);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
active = runtime;
|
|
107
|
+
rememberRuntime(runtime);
|
|
108
|
+
mirrorSessionState(viewSessionState, runtime.sessionState);
|
|
109
|
+
onActivate?.({ projectId: runtime.project.projectId, sessionId: getRuntimeSessionId(runtime), runtime, restoreState });
|
|
110
|
+
return active;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function getIdleRuntimeForProject(project) {
|
|
114
|
+
if (active.project.projectId === project.projectId && !active.turnTask) return active;
|
|
115
|
+
const runtime = await createProjectRuntime(project);
|
|
116
|
+
rememberRuntime(runtime);
|
|
117
|
+
return runtime;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function findIdleRuntime(projectId, { allowSessionRuntime = true } = {}) {
|
|
121
|
+
return Array.from(runtimes.values()).find((runtime) => {
|
|
122
|
+
if (runtime.project.projectId !== projectId || runtime.turnTask) return false;
|
|
123
|
+
return allowSessionRuntime || !getRuntimeSessionId(runtime);
|
|
124
|
+
}) ?? null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function rememberRuntime(runtime) {
|
|
128
|
+
const key = runtimeKey(runtime.project.projectId, getRuntimeSessionId(runtime));
|
|
129
|
+
for (const [candidateKey, candidate] of runtimes) {
|
|
130
|
+
if (candidate === runtime && candidateKey !== key) runtimes.delete(candidateKey);
|
|
131
|
+
}
|
|
132
|
+
runtimes.set(key, runtime);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function dispose() {
|
|
136
|
+
if (disposed) return;
|
|
137
|
+
disposed = true;
|
|
138
|
+
const uniqueRuntimes = new Set(runtimes.values());
|
|
139
|
+
await Promise.all(Array.from(uniqueRuntimes, async (runtime) => {
|
|
140
|
+
await runtime.runner.dispose?.();
|
|
141
|
+
runtime.memoryStore?.close?.();
|
|
142
|
+
}));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function loadWorkspaceMarchSessionState({ runtime, session }) {
|
|
147
|
+
const stored = loadMarchSessionStateForPiBackend({
|
|
148
|
+
projectMarchDir: runtime.projectMarchDir,
|
|
149
|
+
sessionId: session.id,
|
|
150
|
+
sessionRef: session.path,
|
|
151
|
+
});
|
|
152
|
+
if (!stored) throw new Error(`March session state not found for ${session.id}; refusing partial resume`);
|
|
153
|
+
if (stored.state.cwd && stored.state.cwd !== runtime.runner.engine.cwd) {
|
|
154
|
+
throw new Error(`March session state cwd mismatch for ${session.id}: ${stored.state.cwd}`);
|
|
155
|
+
}
|
|
156
|
+
const { renderTimeline: _renderTimeline, ...contextState } = stored.state;
|
|
157
|
+
return contextState;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function getRuntimeSessionId(runtime) {
|
|
161
|
+
return runtime.runner.getSessionStats?.()?.sessionId ?? runtime.sessionState?.sessionId ?? null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function syncSessionState(runtime, sessionId) {
|
|
165
|
+
if (!runtime.sessionState || !sessionId) return;
|
|
166
|
+
runtime.sessionState.sessionId = sessionId;
|
|
167
|
+
runtime.sessionState.sessionDir = runtime.sessionsRoot ? join(runtime.sessionsRoot, sessionId) : runtime.sessionState.sessionDir;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function mirrorSessionState(target, source) {
|
|
171
|
+
if (!target || !source) return;
|
|
172
|
+
target.sessionId = source.sessionId;
|
|
173
|
+
target.sessionDir = source.sessionDir;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function runtimeKey(projectId, sessionId = null) {
|
|
177
|
+
return `${projectId}:${sessionId ?? ""}`;
|
|
178
|
+
}
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
export async function createSidecarWriteFailure({ runtimeHost, sourceSessionFile, action, cause }) {
|
|
2
|
-
const causeMessage = cause?.message ?? String(cause);
|
|
3
|
-
const baseMessage = `failed to write pi session sidecar after ${action}: ${causeMessage}`;
|
|
4
|
-
try {
|
|
5
|
-
await runtimeHost.switchSession(sourceSessionFile);
|
|
6
|
-
return new Error(`${baseMessage}; rolled back to source session`);
|
|
7
|
-
} catch (rollbackErr) {
|
|
8
|
-
return new Error(`${baseMessage}; rollback failed: ${rollbackErr.message}`);
|
|
9
|
-
}
|
|
10
|
-
}
|
package/src/cli/permissions.mjs
DELETED
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
// ── Permission categories ──────────────────────────────────────────────
|
|
2
|
-
export const PERM = Object.freeze({
|
|
3
|
-
READ_ONLY: "read_only",
|
|
4
|
-
FILE_WRITE: "file_write",
|
|
5
|
-
COMMAND_EXEC: "command_exec",
|
|
6
|
-
NETWORK_EXTERNAL: "network_external",
|
|
7
|
-
});
|
|
8
|
-
|
|
9
|
-
const CATEGORY_LABEL = {
|
|
10
|
-
[PERM.READ_ONLY]: "read-only",
|
|
11
|
-
[PERM.FILE_WRITE]: "file write",
|
|
12
|
-
[PERM.COMMAND_EXEC]: "command exec",
|
|
13
|
-
[PERM.NETWORK_EXTERNAL]: "network",
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
export function permissionLabel(cat) {
|
|
17
|
-
return CATEGORY_LABEL[cat] ?? cat;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
// ── Permitted modes ────────────────────────────────────────────────────
|
|
21
|
-
export const MODE = Object.freeze({
|
|
22
|
-
DEFAULT: "default",
|
|
23
|
-
DONT_ASK: "dontAsk",
|
|
24
|
-
BYPASS: "bypassPermissions",
|
|
25
|
-
ACCEPT_EDITS: "acceptEdits",
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
// ── Default tool → category mapping ────────────────────────────────────
|
|
29
|
-
const DEFAULT_CATEGORIES = {
|
|
30
|
-
context_stats: PERM.READ_ONLY,
|
|
31
|
-
find: PERM.READ_ONLY,
|
|
32
|
-
edit_file: PERM.FILE_WRITE,
|
|
33
|
-
command_exec: PERM.COMMAND_EXEC,
|
|
34
|
-
terminal_spawn: PERM.COMMAND_EXEC,
|
|
35
|
-
terminal_send: PERM.COMMAND_EXEC,
|
|
36
|
-
terminal_list: PERM.COMMAND_EXEC,
|
|
37
|
-
terminal_kill: PERM.COMMAND_EXEC,
|
|
38
|
-
terminal_resize: PERM.COMMAND_EXEC,
|
|
39
|
-
terminal_clear: PERM.COMMAND_EXEC,
|
|
40
|
-
terminal_search: PERM.COMMAND_EXEC,
|
|
41
|
-
terminal_read: PERM.COMMAND_EXEC,
|
|
42
|
-
terminal_snapshot: PERM.COMMAND_EXEC,
|
|
43
|
-
external_web_search: PERM.NETWORK_EXTERNAL,
|
|
44
|
-
web_fetch: PERM.NETWORK_EXTERNAL,
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
export function createPermissionController({
|
|
48
|
-
mode = MODE.DEFAULT,
|
|
49
|
-
toolCategories = {},
|
|
50
|
-
onRequestApproval = null,
|
|
51
|
-
} = {}) {
|
|
52
|
-
const sessionApprovals = new Map();
|
|
53
|
-
const categories = { ...DEFAULT_CATEGORIES, ...toolCategories };
|
|
54
|
-
|
|
55
|
-
function getCategory(toolName) {
|
|
56
|
-
if (categories[toolName] !== undefined) return categories[toolName];
|
|
57
|
-
// MCP and unknown tools: default to most restrictive
|
|
58
|
-
if (toolName.startsWith("mcp__")) return PERM.NETWORK_EXTERNAL;
|
|
59
|
-
return PERM.COMMAND_EXEC;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function setCategory(toolName, category) {
|
|
63
|
-
categories[toolName] = category;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function check(toolName) {
|
|
67
|
-
const category = getCategory(toolName);
|
|
68
|
-
|
|
69
|
-
if (category === PERM.READ_ONLY) return { behavior: "allow" };
|
|
70
|
-
if (mode === MODE.BYPASS) return { behavior: "allow" };
|
|
71
|
-
if (mode === MODE.DONT_ASK) {
|
|
72
|
-
return { behavior: "deny", message: `Tool '${toolName}' requires ${permissionLabel(category)} permission, but permission mode is 'dontAsk'.` };
|
|
73
|
-
}
|
|
74
|
-
if (sessionApprovals.has(toolName)) return { behavior: "allow" };
|
|
75
|
-
|
|
76
|
-
return { behavior: "ask", category, toolName };
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function approve(toolName) {
|
|
80
|
-
sessionApprovals.set(toolName, true);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function isApproved(toolName) {
|
|
84
|
-
return sessionApprovals.has(toolName);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
async function requestApproval(toolName, params, requestFn) {
|
|
88
|
-
const decision = check(toolName);
|
|
89
|
-
if (decision.behavior !== "ask") return decision;
|
|
90
|
-
if (!requestFn) return decision;
|
|
91
|
-
const ok = await requestFn({ toolName, params, category: decision.category });
|
|
92
|
-
if (ok) {
|
|
93
|
-
approve(toolName);
|
|
94
|
-
return { behavior: "allow" };
|
|
95
|
-
}
|
|
96
|
-
return {
|
|
97
|
-
behavior: "deny",
|
|
98
|
-
message: `User denied ${toolName} (requires ${permissionLabel(decision.category)} permission).`,
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
return { check, approve, isApproved, getCategory, setCategory, requestApproval, get mode() { return mode; } };
|
|
103
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
// Legacy session switch command removed. Use /session to restore previous pi sessions.
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import { permissionLabel } from "../permissions.mjs";
|
|
2
|
-
import { formatToolStartLine } from "./tool-rendering.mjs";
|
|
3
|
-
import { brightBlack, yellow } from "./ui-theme.mjs";
|
|
4
|
-
|
|
5
|
-
export async function requestToolPermission({ toolName, params, category, output, selectList, requestRender }) {
|
|
6
|
-
const label = permissionLabel(category);
|
|
7
|
-
output.writeln(yellow(`● ${toolName} needs ${label} permission`));
|
|
8
|
-
output.writeln(brightBlack(` ${formatToolStartLine(toolName, params)}`));
|
|
9
|
-
requestRender();
|
|
10
|
-
const choice = await selectList({
|
|
11
|
-
items: [
|
|
12
|
-
{ label: "Approve once", description: `Allow ${toolName} this time (${label})` },
|
|
13
|
-
{ label: "Deny", description: "Block this tool call" },
|
|
14
|
-
],
|
|
15
|
-
width: 58,
|
|
16
|
-
});
|
|
17
|
-
return choice?.label === "Approve once";
|
|
18
|
-
}
|
package/src/session/persist.mjs
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
// Legacy session persistence removed. All sessions use pi JSONL format.
|