march-cli 0.1.36 → 0.1.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/package.json +1 -1
  2. package/src/agent/code-search/tool.mjs +1 -1
  3. package/src/agent/runner/runner-utils.mjs +20 -0
  4. package/src/agent/runner.mjs +16 -18
  5. package/src/agent/runtime/remote-ui-client.mjs +0 -1
  6. package/src/agent/runtime/runner-process-client.mjs +7 -0
  7. package/src/agent/runtime/runner-process-factory.mjs +7 -3
  8. package/src/agent/runtime/runner-runtime-host.mjs +2 -2
  9. package/src/agent/runtime/ui-event-bridge.mjs +0 -2
  10. package/src/agent/session/session-options.mjs +2 -2
  11. package/src/agent/tools.mjs +5 -23
  12. package/src/agent/turn/turn-events.mjs +41 -0
  13. package/src/agent/turn/turn-runner.mjs +5 -2
  14. package/src/cli/args.mjs +0 -3
  15. package/src/cli/commands/registry/slash-command-registry.mjs +2 -0
  16. package/src/cli/fallback-ui.mjs +0 -2
  17. package/src/cli/input/history-store.mjs +65 -3
  18. package/src/cli/input/mode-state.mjs +1 -1
  19. package/src/cli/repl-loop.mjs +75 -25
  20. package/src/cli/startup/app-runtime.mjs +72 -31
  21. package/src/cli/startup/create-runtime-runner.mjs +5 -46
  22. package/src/cli/startup/startup-session.mjs +3 -13
  23. package/src/cli/tui/input/history-navigation-controller.mjs +56 -0
  24. package/src/cli/turn/turn-input-preparer.mjs +0 -1
  25. package/src/cli/ui.mjs +9 -6
  26. package/src/cli/workspace/command.mjs +147 -0
  27. package/src/cli/workspace/output-router.mjs +108 -0
  28. package/src/cli/workspace/project-runtime.mjs +92 -0
  29. package/src/config/features.mjs +0 -1
  30. package/src/context/engine.mjs +4 -2
  31. package/src/context/system-core/base.md +4 -1
  32. package/src/extensions/lifecycle-adapter.mjs +1 -1
  33. package/src/history/runner.mjs +11 -0
  34. package/src/history/store.mjs +129 -0
  35. package/src/history/tool.mjs +39 -0
  36. package/src/lsp/client.mjs +12 -5
  37. package/src/lsp/service.mjs +15 -3
  38. package/src/main.mjs +5 -2
  39. package/src/notification/desktop-notifier.mjs +16 -8
  40. package/src/web-ui/command.mjs +2 -2
  41. package/src/web-ui/dist/assets/index-BQtl1uQs.css +1 -0
  42. package/src/web-ui/dist/assets/index-DrlJis_D.js +1845 -0
  43. package/src/web-ui/dist/index.html +13 -0
  44. package/src/web-ui/runtime-host.mjs +5 -25
  45. package/src/web-ui/session-manager.mjs +2 -2
  46. package/src/web-ui/src/components/timeline/TimelineBlocks.tsx +2 -10
  47. package/src/web-ui/src/mockData.ts +1 -8
  48. package/src/web-ui/src/model.ts +0 -2
  49. package/src/web-ui/src/runtime/client.ts +0 -1
  50. package/src/web-ui/src/runtime/runtimeTimeline.ts +1 -3
  51. package/src/web-ui/src/styles/shell.css +1 -2
  52. package/src/web-ui/src/timelineAdapter.ts +1 -2
  53. package/src/workspace/project-id.mjs +14 -0
  54. package/src/workspace/project-registry.mjs +74 -0
  55. package/src/workspace/session-index.mjs +75 -0
  56. package/src/workspace/supervisor.mjs +172 -0
  57. package/src/cli/permissions.mjs +0 -103
  58. package/src/cli/tui/permission-request-ui.mjs +0 -18
@@ -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>
@@ -3,17 +3,12 @@ import { basename, join, resolve } from "node:path";
3
3
  import { existsSync, mkdirSync, readdirSync, statSync } from "node:fs";
4
4
  import { createMarchAuthStorage } from "../auth/storage.mjs";
5
5
  import { createRuntimeRunner } from "../cli/startup/create-runtime-runner.mjs";
6
- import { createPermissionController, MODE } from "../cli/permissions.mjs";
7
6
  import { createCliShellRuntime } from "../shell/cli-runtime.mjs";
8
7
  import { MarkdownMemoryStore } from "../memory/markdown-store.mjs";
9
- import { createMarkdownMemoryTools } from "../memory/markdown-tools.mjs";
10
8
  import { resolveMemoryRoot } from "../memory/root.mjs";
11
9
  import { defaultProfilePaths, ensureProfileFiles } from "../context/profiles.mjs";
12
10
  import { loadOrCreateProjectId } from "../cli/startup/startup-session.mjs";
13
- import { createWebToolsFromConfig } from "../web/tools.mjs";
14
11
  import { createLogger, installProcessLogHandlers } from "../debug/logger.mjs";
15
- import { createModelContextDumper } from "../debug/model-context-dumper.mjs";
16
- import { createDesktopTurnNotifier } from "../notification/desktop-notifier.mjs";
17
12
  import { discoverProjectExtensionPaths } from "../extensions/discovery.mjs";
18
13
  import { loadProjectLifecycleHookManifests } from "../extensions/lifecycle-manifest.mjs";
19
14
  import { normalizeRemoteMemorySources } from "../memory/remote/config.mjs";
@@ -22,7 +17,7 @@ import { prepareTurnInput } from "../cli/turn/turn-input-preparer.mjs";
22
17
  const MAX_WORKSPACE_DEPTH = 3;
23
18
  const MAX_WORKSPACE_ENTRIES = 200;
24
19
 
25
- export async function createWebRuntimeHost({ args, config, cwd, stateRoot, useRuntimeProcess = true } = {}) {
20
+ export async function createWebRuntimeHost({ args, config, cwd, stateRoot } = {}) {
26
21
  stateRoot ??= join(homedir(), ".march");
27
22
  if (!existsSync(stateRoot)) mkdirSync(stateRoot, { recursive: true });
28
23
  const logger = createLogger({ logDir: join(stateRoot, "logs") });
@@ -40,14 +35,10 @@ export async function createWebRuntimeHost({ args, config, cwd, stateRoot, useRu
40
35
 
41
36
  const memoryStore = new MarkdownMemoryStore({ root: memoryRoot });
42
37
  const remoteMemorySources = normalizeRemoteMemorySources(config);
43
- const memoryTools = createMarkdownMemoryTools(memoryStore, { remoteSources: remoteMemorySources });
44
38
  const shellRuntime = args.shellRuntime ? createCliShellRuntime({ cwd }) : null;
45
39
  const extensionPaths = discoverProjectExtensionPaths(cwd);
46
40
  const lifecycleManifests = loadProjectLifecycleHookManifests(cwd);
47
41
  const contextDumpRoot = resolve(projectMarchDir, "context-dumps", Date.now().toString(36));
48
- const modelContextDumper = createModelContextDumper({ enabled: args.dumpContext, rootDir: contextDumpRoot });
49
- const permissionController = createPermissionController({ mode: args.permissionMode ?? MODE.BYPASS });
50
- const turnNotifier = createDesktopTurnNotifier({ enabled: Boolean(config.notifications?.turnEnd), config: config.notifications });
51
42
  const ui = createHeadlessWebUi();
52
43
  const currentProject = basename(cwd);
53
44
  const namespace = loadOrCreateProjectId(projectMarchDir);
@@ -64,7 +55,6 @@ export async function createWebRuntimeHost({ args, config, cwd, stateRoot, useRu
64
55
  namespace,
65
56
  projectMarchDir,
66
57
  extensionPaths,
67
- permissionMode: args.permissionMode,
68
58
  shellRuntime: Boolean(shellRuntime),
69
59
  lifecycleHooks: lifecycleManifests.hooks,
70
60
  lifecycleDiagnostics: lifecycleManifests.diagnostics,
@@ -72,20 +62,9 @@ export async function createWebRuntimeHost({ args, config, cwd, stateRoot, useRu
72
62
  remoteMemorySources,
73
63
  };
74
64
  const runner = await createRuntimeRunner({
75
- useRuntimeProcess,
76
65
  runnerOptions,
77
66
  ui,
78
- memoryStore,
79
- memoryTools,
80
67
  shellRuntime,
81
- webTools: createWebToolsFromConfig(config),
82
- usePiSessions: true,
83
- usePiRuntimeHost: true,
84
- authStorage: authConfig.authStorage,
85
- permissionController,
86
- modelContextDumper,
87
- turnNotifier,
88
- logger,
89
68
  });
90
69
  let turnRunning = false;
91
70
 
@@ -100,9 +79,10 @@ export async function createWebRuntimeHost({ args, config, cwd, stateRoot, useRu
100
79
  async runTurn(prompt) {
101
80
  if (turnRunning) throw new Error("A turn is already running");
102
81
  turnRunning = true;
103
- const input = prepareTurnInput({ prompt, runner, memoryStore, currentProject });
104
- runner.runtimeUiEvents.emit({ type: "web_user_message", text: input.userMessage });
82
+ memoryStore.beginTurn();
105
83
  try {
84
+ const input = prepareTurnInput({ prompt, runner, memoryStore, currentProject });
85
+ runner.runtimeUiEvents.emit({ type: "web_user_message", text: input.userMessage });
106
86
  return await runner.runTurn(input.fullPrompt, input.userMessage, input.runOptions);
107
87
  } finally {
108
88
  turnRunning = false;
@@ -127,7 +107,7 @@ export function createHeadlessWebUi() {
127
107
  providerQuotaSnapshot: () => {},
128
108
  clearOutput: () => {}, restoreTranscript: () => {}, setStatusBar: () => {},
129
109
  turnStart: () => {}, turnEnd: () => {}, retryStart: () => {}, retryEnd: () => {},
130
- editDiff: () => {}, requestPermission: async () => true,
110
+ editDiff: () => {},
131
111
  setEscapeHandler: () => {}, setCtrlCHandler: () => {}, setShiftTabHandler: () => {},
132
112
  setCtrlTHandler: () => {}, setCtrlLHandler: () => {}, setPasteImageHandler: () => {},
133
113
  getInputText: () => "", insertTextAtCursor: () => {}, openExternalEditor: () => {},
@@ -3,7 +3,7 @@ import { homedir } from "node:os";
3
3
  import { basename, resolve } from "node:path";
4
4
  import { createWebRuntimeHost } from "./runtime-host.mjs";
5
5
 
6
- export function createWebSessionManager({ args, config, launchCwd, stateRoot, useRuntimeProcess = true } = {}) {
6
+ export function createWebSessionManager({ args, config, launchCwd, stateRoot } = {}) {
7
7
  const sessions = new Map();
8
8
  const activities = [];
9
9
  let activeSessionId = null;
@@ -12,7 +12,7 @@ export function createWebSessionManager({ args, config, launchCwd, stateRoot, us
12
12
  async function createSession(workspacePath) {
13
13
  const workspace = resolveWorkspace(workspacePath, launchCwd);
14
14
  const id = `session-${Date.now().toString(36)}-${nextSessionNumber++}`;
15
- const runtime = await createWebRuntimeHost({ args, config, cwd: workspace, stateRoot, useRuntimeProcess });
15
+ const runtime = await createWebRuntimeHost({ args, config, cwd: workspace, stateRoot });
16
16
  const session = { id, workspace, title: basename(workspace) || workspace, runtime, createdAt: Date.now() };
17
17
  sessions.set(id, session);
18
18
  activeSessionId = id;
@@ -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
- case "permission":
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
- function PermissionBlock({ item }: { item: Extract<TimelineItem, { kind: "permission" }> }) {
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: [
@@ -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
- case "permission_request":
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
- .permission-block.pending strong { color: var(--color-warning); }
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
- case "permission_request":
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,75 @@
1
+ import { resolve } from "node:path";
2
+ import { listPiSessionInfos } from "../session/pi-manager.mjs";
3
+ import { listRegisteredProjects } from "./project-registry.mjs";
4
+
5
+ export async function listWorkspaceSessions({ stateRoot, currentProjectId = null, listSessions = listPiSessionInfos }) {
6
+ const projects = listRegisteredProjects({ stateRoot });
7
+ const entries = [];
8
+ for (const project of projects) {
9
+ const projectMarchDir = resolve(project.rootPath, ".march");
10
+ let sessions = [];
11
+ try {
12
+ sessions = await listSessions({ cwd: project.rootPath, projectMarchDir });
13
+ } catch {
14
+ sessions = [];
15
+ }
16
+ entries.push({
17
+ ...project,
18
+ current: project.projectId === currentProjectId,
19
+ sessions,
20
+ sessionCount: sessions.length,
21
+ });
22
+ }
23
+ return entries;
24
+ }
25
+
26
+ export function buildWorkspaceSessionSelectItems(projects, currentSessionId = null) {
27
+ const items = [];
28
+ for (const project of projects) {
29
+ if (project.sessions.length === 0) {
30
+ items.push({
31
+ value: `${project.projectId}:new`,
32
+ label: `${project.displayName} / + new session`,
33
+ description: project.current ? "current project" : project.rootPath,
34
+ project,
35
+ session: null,
36
+ kind: "new-session",
37
+ });
38
+ continue;
39
+ }
40
+ for (const session of project.sessions) {
41
+ const current = project.current && session.id === currentSessionId;
42
+ items.push({
43
+ value: `${project.projectId}:${session.id}`,
44
+ label: `${project.displayName} / ${session.name || session.firstMessage || session.id}`,
45
+ description: `${current ? "current · " : ""}${formatWorkspaceSessionTime(session.savedAt)} · ${project.rootPath}`,
46
+ project,
47
+ session,
48
+ kind: "session",
49
+ });
50
+ }
51
+ }
52
+ return items.sort(compareWorkspaceItems);
53
+ }
54
+
55
+ export function workspaceSessionSearchText(item) {
56
+ const session = item?.session;
57
+ const project = item?.project;
58
+ return [item?.label, item?.description, project?.displayName, project?.rootPath, session?.id, session?.name, session?.firstMessage]
59
+ .filter(Boolean)
60
+ .join(" ");
61
+ }
62
+
63
+ function compareWorkspaceItems(a, b) {
64
+ const aCurrent = a.project?.current ? 1 : 0;
65
+ const bCurrent = b.project?.current ? 1 : 0;
66
+ if (aCurrent !== bCurrent) return bCurrent - aCurrent;
67
+ const aTime = a.session?.savedAt || a.project?.lastOpenedAt || "";
68
+ const bTime = b.session?.savedAt || b.project?.lastOpenedAt || "";
69
+ return String(bTime).localeCompare(String(aTime));
70
+ }
71
+
72
+ function formatWorkspaceSessionTime(value) {
73
+ if (!value) return "no saved time";
74
+ return String(value).slice(0, 16).replace("T", " ");
75
+ }
@@ -0,0 +1,172 @@
1
+ import { join } from "node:path";
2
+ import { loadPiSessionSidecar } from "../session/sidecar.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 });
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
+ if (session?.path && getRuntimeSessionId(runtime) !== session.id) {
100
+ const restoreState = loadWorkspacePiSessionState({ runtime, session });
101
+ await runtime.runner.switchPiSession(session.path, restoreState);
102
+ syncSessionState(runtime, session.id);
103
+ }
104
+
105
+ active = runtime;
106
+ rememberRuntime(runtime);
107
+ mirrorSessionState(viewSessionState, runtime.sessionState);
108
+ onActivate?.({ projectId: runtime.project.projectId, sessionId: getRuntimeSessionId(runtime), runtime });
109
+ return active;
110
+ }
111
+
112
+ async function getIdleRuntimeForProject(project) {
113
+ if (active.project.projectId === project.projectId && !active.turnTask) return active;
114
+ const runtime = await createProjectRuntime(project);
115
+ rememberRuntime(runtime);
116
+ return runtime;
117
+ }
118
+
119
+ function findIdleRuntime(projectId, { allowSessionRuntime = true } = {}) {
120
+ return Array.from(runtimes.values()).find((runtime) => {
121
+ if (runtime.project.projectId !== projectId || runtime.turnTask) return false;
122
+ return allowSessionRuntime || !getRuntimeSessionId(runtime);
123
+ }) ?? null;
124
+ }
125
+
126
+ function rememberRuntime(runtime) {
127
+ const key = runtimeKey(runtime.project.projectId, getRuntimeSessionId(runtime));
128
+ for (const [candidateKey, candidate] of runtimes) {
129
+ if (candidate === runtime && candidateKey !== key) runtimes.delete(candidateKey);
130
+ }
131
+ runtimes.set(key, runtime);
132
+ }
133
+
134
+ async function dispose() {
135
+ if (disposed) return;
136
+ disposed = true;
137
+ const uniqueRuntimes = new Set(runtimes.values());
138
+ await Promise.all(Array.from(uniqueRuntimes, async (runtime) => {
139
+ await runtime.runner.dispose?.();
140
+ runtime.memoryStore?.close?.();
141
+ }));
142
+ }
143
+ }
144
+
145
+ function loadWorkspacePiSessionState({ runtime, session }) {
146
+ const sidecar = loadPiSessionSidecar({ projectMarchDir: runtime.projectMarchDir, sessionRef: session.path });
147
+ if (!sidecar) throw new Error(`pi session sidecar not found for ${session.id}; refusing partial resume`);
148
+ if (sidecar.state.cwd && sidecar.state.cwd !== runtime.runner.engine.cwd) {
149
+ throw new Error(`pi session sidecar cwd mismatch for ${session.id}: ${sidecar.state.cwd}`);
150
+ }
151
+ return { ...sidecar.state };
152
+ }
153
+
154
+ function getRuntimeSessionId(runtime) {
155
+ return runtime.runner.getSessionStats?.()?.sessionId ?? runtime.sessionState?.sessionId ?? null;
156
+ }
157
+
158
+ function syncSessionState(runtime, sessionId) {
159
+ if (!runtime.sessionState || !sessionId) return;
160
+ runtime.sessionState.sessionId = sessionId;
161
+ runtime.sessionState.sessionDir = runtime.sessionsRoot ? join(runtime.sessionsRoot, sessionId) : runtime.sessionState.sessionDir;
162
+ }
163
+
164
+ function mirrorSessionState(target, source) {
165
+ if (!target || !source) return;
166
+ target.sessionId = source.sessionId;
167
+ target.sessionDir = source.sessionDir;
168
+ }
169
+
170
+ function runtimeKey(projectId, sessionId = null) {
171
+ return `${projectId}:${sessionId ?? ""}`;
172
+ }
@@ -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
- }