pum-agent 0.2.27-beta.1 → 0.2.28-beta.1

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/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  **A compact coding agent for the terminal.**
6
6
 
7
- Plan, edit, run commands, review Markdown, and coordinate parallel Git worktrees without leaving the TUI.
7
+ Plan, edit, run commands, review Markdown, and coordinate parallel subagents without leaving the TUI.
8
8
 
9
9
  [![CI](https://github.com/eugen1763/Pum/actions/workflows/ci.yml/badge.svg)](https://github.com/eugen1763/Pum/actions/workflows/ci.yml)
10
10
  [![npm beta](https://img.shields.io/npm/v/pum-agent/beta?label=npm%20beta)](https://www.npmjs.com/package/pum-agent)
@@ -51,8 +51,9 @@ drives the actual TUI and converts the captured cells to SVG.
51
51
  ## What it does
52
52
 
53
53
  - **A full coding loop** — `read`, `write`, `edit`, and `bash`, with streaming Markdown, syntax highlighting, usage, cost, and Git status.
54
- - **Parallel subagents** — persistent agents in isolated Git worktrees that message each other durably and report to their spawner. See [Subagents](docs/subagents.md).
54
+ - **Parallel subagents** — persistent agents that share the project by default, with optional isolated Git worktrees. They message each other durably and report to their spawner. See [Subagents](docs/subagents.md).
55
55
  - **Goals that outlive a turn** — `/goal` keeps working, reviewed after each turn by a judge that reads but never writes. See [Goals](docs/goals.md).
56
+ - **Project memory across sessions** — the main agent maintains private Markdown facts that follow linked Git worktrees. See [Tools](docs/tools.md#project-memory).
56
57
  - **Supervised processes** — background shells and external triggers such as `gh run watch`, which wake the exact agent that was waiting. See [Tools](docs/tools.md).
57
58
  - **Layered safeguards** — a path guard on the file tools, deterministic Check mode, and a native OS sandbox for the processes the model starts. See [Safety](docs/security.md).
58
59
  - **A terminal-first look** — nine themes, semantic colour overrides, and optional animation. See [Appearance](docs/appearance.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pum-agent",
3
- "version": "0.2.27-beta.1",
3
+ "version": "0.2.28-beta.1",
4
4
  "description": "A compact terminal coding agent powered by pi and OpenTUI.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -18,6 +18,7 @@ export type AgentTreeRow = {
18
18
  name: string;
19
19
  status?: SubagentSnapshot["status"];
20
20
  readonly?: boolean;
21
+ shared?: boolean;
21
22
  depth: number;
22
23
  metadata?: StatusMetadataValues;
23
24
  };
@@ -38,7 +39,7 @@ export function agentSelectorRowLayout(
38
39
  // One column remains clear for the pinned scrollbar.
39
40
  const contentColumns = Math.max(1, popupColumns - indent - 1);
40
41
  const label = row.status
41
- ? `${row.name}${row.readonly ? " · readonly" : ""} · ${row.status}`
42
+ ? `${row.name}${row.readonly ? " · readonly" : ""}${row.shared ? " · shared" : ""} · ${row.status}`
42
43
  : row.name;
43
44
  const minimumLabelWidth = Math.min(
44
45
  label.length,
@@ -77,9 +78,10 @@ export function buildAgentTree(agents: readonly SubagentSnapshot[]): AgentTreeRo
77
78
  name: agent.name,
78
79
  status: agent.status,
79
80
  readonly: agent.readonly === true,
81
+ shared: agent.usesWorktree === false,
80
82
  depth,
81
83
  metadata: {
82
- branch: agent.worktree.branch ?? null,
84
+ branch: agent.usesWorktree === false ? null : agent.worktree.branch ?? null,
83
85
  outgoingTokens: usage.outgoing,
84
86
  incomingTokens: usage.incoming,
85
87
  cacheReadTokens: usage.cacheRead,
@@ -100,9 +102,10 @@ export function buildAgentTree(agents: readonly SubagentSnapshot[]): AgentTreeRo
100
102
  name: agent.name,
101
103
  status: agent.status,
102
104
  readonly: agent.readonly === true,
105
+ shared: agent.usesWorktree === false,
103
106
  depth: 1,
104
107
  metadata: {
105
- branch: agent.worktree.branch ?? null,
108
+ branch: agent.usesWorktree === false ? null : agent.worktree.branch ?? null,
106
109
  outgoingTokens: usage.outgoing,
107
110
  incomingTokens: usage.incoming,
108
111
  cacheReadTokens: usage.cacheRead,
package/src/animation.tsx CHANGED
@@ -143,7 +143,12 @@ const RULE_CHARS_PER_MS = 0.04;
143
143
  const RULE_HIGHLIGHT_WIDTH = 10;
144
144
  const COMET_CHARS_PER_MS = 0.035;
145
145
  const ELECTRIC_FRAME_MS = 140;
146
- const CONSTELLATION_SPACING = 13;
146
+ /** Average columns per star. Each slot places its own star inside this span. */
147
+ const CONSTELLATION_SPACING = 11;
148
+ /** Base period of one star. A hashed factor spreads the real periods around it. */
149
+ const CONSTELLATION_TWINKLE_MS = 2600;
150
+ /** How much of its period a star burns for. It is dark and moves in the rest. */
151
+ const CONSTELLATION_LIFETIME = 0.72;
147
152
  const RANDOM_CONSTELLATION_CYCLE_MS = 2000;
148
153
  /** How much of one cycle a single sparkle burns for, start to finish. */
149
154
  const RANDOM_CONSTELLATION_LIFETIME = 0.5;
@@ -222,48 +227,6 @@ const ClockContext = createContext<Clock>({
222
227
 
223
228
  export const useClock = () => useContext(ClockContext);
224
229
 
225
- /** The prompt cursor stays visible for most of each slow blink cycle. */
226
- export function inputCursorVisible(elapsedMs: number): boolean {
227
- const phase = ((elapsedMs % CARET_PERIOD_MS) + CARET_PERIOD_MS) % CARET_PERIOD_MS;
228
- return phase < CARET_PERIOD_MS * 0.65;
229
- }
230
-
231
- /** Replace the terminal-defined fast cursor blink with the shared slow clock. */
232
- export function PromptCursorBlink({
233
- inputRef,
234
- active,
235
- }: {
236
- inputRef: RefObject<TextareaRenderable | null>;
237
- active: boolean;
238
- }) {
239
- const { subscribe, enabled } = useClock();
240
-
241
- useEffect(() => {
242
- const input = inputRef.current;
243
- if (!input) return;
244
- input.cursorStyle = { style: "block", blinking: false };
245
- if (!active) {
246
- input.showCursor = false;
247
- return;
248
- }
249
-
250
- input.showCursor = true;
251
- if (!enabled) return;
252
-
253
- let startedAt: number | undefined;
254
- let visible = true;
255
- return subscribe((elapsedMs) => {
256
- startedAt ??= elapsedMs;
257
- const next = inputCursorVisible(elapsedMs - startedAt);
258
- if (next === visible) return;
259
- visible = next;
260
- if (inputRef.current) inputRef.current.showCursor = next;
261
- });
262
- }, [active, enabled, inputRef, subscribe]);
263
-
264
- return null;
265
- }
266
-
267
230
  /**
268
231
  * One frame callback for the whole app. Animated components write straight to
269
232
  * their own renderable, so no React render happens per frame. The renderer is
@@ -700,6 +663,44 @@ const hashPosition = (frame: number, seed: number, width: number) => {
700
663
  return Math.abs(value) % width;
701
664
  };
702
665
 
666
+ export type ConstellationStar = { column: number; strength: number };
667
+
668
+ /**
669
+ * One star of the constellation field, for the slot of columns it belongs to.
670
+ *
671
+ * The field used to be a fixed grid: a star every `CONSTELLATION_SPACING`
672
+ * columns, in the same place for the whole run. Each slot now holds a star with
673
+ * its own period, its own start, its own peak brightness, and a column that is
674
+ * re-drawn for every life. A star is dark between two lives, so it never moves
675
+ * while it is visible. Returns `null` while the slot is empty.
676
+ */
677
+ export function constellationStar(
678
+ slot: number,
679
+ elapsedMs: number,
680
+ role: WorkingRuleRole,
681
+ ): ConstellationStar | null {
682
+ if (slot < 0) return null;
683
+ const seed = roleSeed(role);
684
+ const period = CONSTELLATION_TWINKLE_MS *
685
+ (0.55 + (hashPosition(slot * 13 + 7, seed + 3, 100) / 100) * 1.05);
686
+ const offset = (hashPosition(slot * 29 + 5, seed + 11, 1000) / 1000) * period;
687
+ const time = elapsedMs + offset;
688
+ const life = (time % period) / period;
689
+ if (life >= CONSTELLATION_LIFETIME) return null;
690
+
691
+ const epoch = Math.floor(time / period);
692
+ // Two columns of margin on each side keep the halo of one star clear of the
693
+ // next slot, so no two stars ever touch.
694
+ const span = CONSTELLATION_SPACING - 4;
695
+ const column = slot * CONSTELLATION_SPACING + 2 +
696
+ hashPosition(epoch * 31 + slot, seed * 7 + slot, span);
697
+ const amplitude = 0.5 + (hashPosition(epoch * 17 + slot, seed + 23, 100) / 100) * 0.5;
698
+ const strength = clampStrength(
699
+ Math.sin((life / CONSTELLATION_LIFETIME) * Math.PI) ** 1.2 * amplitude,
700
+ );
701
+ return { column, strength };
702
+ }
703
+
703
704
  /** Choose stable, random-looking sparkle centers for one two-second cycle. */
704
705
  export function randomConstellationCenters(
705
706
  width: number,
@@ -796,20 +797,24 @@ export function workingRuleCell(
796
797
  }
797
798
 
798
799
  if (mode === "constellation") {
799
- const seed = roleSeed(role);
800
- const spacing = (offset: number) => (column + offset + seed * 3) % CONSTELLATION_SPACING === 0;
801
- // A star ends at its own cell unless it carries a halo, which is what
802
- // makes it bloom on the rule rather than switch on and off in place.
803
- const halo = spacing(1) || spacing(-1);
804
- if (!spacing(0) && !halo) return { strength: 0, glyph: "─" };
805
- const star = spacing(0) ? column : spacing(1) ? column + 1 : column - 1;
806
- // Stars sit thirteen columns apart, so any fixed phase step per column put
807
- // them all within a fifth of a radian of each other and they blinked in
808
- // unison. A hashed offset gives each one its own place in the cycle.
809
- const phase = elapsedMs / 620 + hashPosition(star, seed, 628) / 100;
810
- const peak = 0.12 + ((Math.sin(phase) + 1) / 2) * 0.88;
811
- if (!spacing(0)) return { strength: peak * CONSTELLATION_HALO, glyph: "·" };
812
- return { strength: peak, glyph: peak > 0.82 ? "✦" : peak > 0.48 ? "✧" : "·" };
800
+ const slot = Math.floor(column / CONSTELLATION_SPACING);
801
+ let strength = 0;
802
+ let glyph = "─";
803
+ // A star can sit in the neighbouring slot and still reach this column with
804
+ // its halo, which is what makes it bloom instead of switching on in place.
805
+ for (let index = slot - 1; index <= slot + 1; index++) {
806
+ const star = constellationStar(index, elapsedMs, role);
807
+ if (!star || star.column >= width) continue;
808
+ const distance = Math.abs(column - star.column);
809
+ if (distance > 1) continue;
810
+ const lit = distance === 0 ? star.strength : star.strength * CONSTELLATION_HALO;
811
+ if (lit <= strength) continue;
812
+ strength = lit;
813
+ glyph = distance > 0 ? "·"
814
+ : star.strength > 0.82 ? "✦"
815
+ : star.strength > 0.48 ? "✧" : "·";
816
+ }
817
+ return { strength, glyph };
813
818
  }
814
819
 
815
820
  if (mode === "random-constellation") {
package/src/app.tsx CHANGED
@@ -14,7 +14,6 @@ import { Component, memo, useEffect, useLayoutEffect, useMemo, useRef, useState,
14
14
  import {
15
15
  AnimationProvider,
16
16
  PlaceholderWave,
17
- PromptCursorBlink,
18
17
  supportsTrueColor,
19
18
  useWorkingRule,
20
19
  type WorkingRuleLabel,
@@ -233,6 +232,7 @@ import {
233
232
  type ShellManagerLike,
234
233
  } from "./processes-popup";
235
234
  import type { TerminalTitleController } from "./terminal-title";
235
+ import { resolveOrcaStatusState, type OrcaStatusController } from "./orca-status";
236
236
  import { readClipboardText } from "./text-paste";
237
237
  import { copyTextToClipboard } from "./clipboard";
238
238
  import { NewsPopup } from "./news-popup";
@@ -832,6 +832,7 @@ export function App({
832
832
  shellManager,
833
833
  messageCacheController,
834
834
  terminalTitle,
835
+ orcaStatus,
835
836
  startupWarnings = [],
836
837
  onSandboxModeChange,
837
838
  sandboxWarningSource,
@@ -871,6 +872,7 @@ export function App({
871
872
  shellManager?: ShellManagerLike;
872
873
  messageCacheController?: MessageCacheController;
873
874
  terminalTitle?: TerminalTitleController;
875
+ orcaStatus?: OrcaStatusController;
874
876
  /** Visible process-local warnings. These lines never enter pi session context. */
875
877
  startupWarnings?: readonly string[];
876
878
  onSandboxModeChange?: (mode: NonNullable<PumSettings["sandboxMode"]>) => void;
@@ -1243,7 +1245,9 @@ export function App({
1243
1245
  });
1244
1246
  const visibleModelId = activeAgent?.modelId.split("/").slice(1).join("/") || modelId;
1245
1247
  const visibleThinkingLevel = activeAgent?.thinkingLevel ?? thinkingLevel;
1246
- const visibleBranch = activeAgent?.worktree.branch ?? branch;
1248
+ const visibleBranch = activeAgent?.usesWorktree === false
1249
+ ? branch
1250
+ : activeAgent?.worktree.branch ?? branch;
1247
1251
  const visibleElapsedSec = activeAgent ? agentElapsedSec : elapsedSec;
1248
1252
  const visibleUsage = activeAgent?.usage ?? usage;
1249
1253
  const agentTreeRows = buildAgentTree(agents);
@@ -1950,11 +1954,28 @@ export function App({
1950
1954
  useEffect(() => {
1951
1955
  // A delegate answering counts as work even though it is not a managed
1952
1956
  // agent, or the terminal reads as idle while AFK decides something.
1957
+ orcaStatus?.update({
1958
+ state: resolveOrcaStatusState({
1959
+ working: busy || activeSubagentCount > 0 || afkAnswering,
1960
+ waitingForUser: Boolean(visibleQuestionnaire),
1961
+ }),
1962
+ model: session.agent.state.model?.id,
1963
+ sessionKey: session.sessionId,
1964
+ });
1953
1965
  terminalTitle?.update({
1954
1966
  working: busy || activeSubagentCount > 0 || afkAnswering,
1955
1967
  activeSubagentCount,
1956
1968
  });
1957
- }, [terminalTitle, busy, activeSubagentCount, afkAnswering]);
1969
+ }, [
1970
+ terminalTitle,
1971
+ orcaStatus,
1972
+ busy,
1973
+ activeSubagentCount,
1974
+ afkAnswering,
1975
+ visibleQuestionnaire,
1976
+ session.sessionId,
1977
+ session.agent.state.model?.id,
1978
+ ]);
1958
1979
 
1959
1980
  useEffect(() => spawnPreviewManager?.subscribe(() => {
1960
1981
  setSpawnPreviewRevision((revision) => revision + 1);
@@ -4181,7 +4202,10 @@ export function App({
4181
4202
  appendRequesterLine(requesterAgentId, {
4182
4203
  kind: "text",
4183
4204
  role: "system",
4184
- text: `background agent started: ${spawned.name} (${spawned.id})\n${spawned.worktree.branch}\n${spawned.worktree.path}`,
4205
+ text: `background agent started: ${spawned.name} (${spawned.id})\n`
4206
+ + (spawned.usesWorktree
4207
+ ? `${spawned.worktree.branch}\n${spawned.worktree.path}`
4208
+ : `shared directory\n${spawned.worktree.path}`),
4185
4209
  });
4186
4210
  })().catch((error) => {
4187
4211
  appendRequesterLine(requesterAgentId, {
@@ -4443,7 +4467,7 @@ export function App({
4443
4467
  };
4444
4468
 
4445
4469
  const cachedBatchDisplay = (prompts: readonly string[]): string => [
4446
- `Run ${prompts.length} cached tasks with worktree subagents:`,
4470
+ `Run ${prompts.length} cached tasks with subagents:`,
4447
4471
  ...prompts.map((prompt, index) => `${index + 1}. ${prompt}`),
4448
4472
  ].join("\n");
4449
4473
 
@@ -6007,14 +6031,12 @@ export function App({
6007
6031
  keyBindings={PROMPT_TEXTAREA_KEY_BINDINGS}
6008
6032
  scrollMargin={1}
6009
6033
  scrollSpeed={PROMPT_SCROLL_SPEED}
6010
- cursorStyle={{ style: "block", blinking: false }}
6011
6034
  focused={promptFocused}
6012
6035
  onContentChange={handleTextareaChange}
6013
6036
  onCursorChange={scheduleInputMetrics}
6014
6037
  onSubmit={() => shellModeRef.current ? submitShellCommand() : submitPrompt()}
6015
6038
  style={{ width: promptInputColumns, flexShrink: 0, minWidth: 0, height: inputRows }}
6016
6039
  />
6017
- <PromptCursorBlink inputRef={inputRef} active={promptFocused} />
6018
6040
  {/* Reserve six columns on normal terminals. This forces wrapping
6019
6041
  before cursor movement can briefly overdraw the terminal edge. */}
6020
6042
  <box style={{ width: promptRightColumns, height: inputRows, flexShrink: 0 }} />
package/src/commands.ts CHANGED
@@ -31,7 +31,7 @@ export const COMMANDS: Command[] = [
31
31
  },
32
32
  {
33
33
  name: "/background",
34
- description: "Start a managed worktree agent for the selected transcript",
34
+ description: "Start a shared-project agent for the selected transcript",
35
35
  },
36
36
  {
37
37
  name: "/history",
package/src/headless.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  } from "@earendil-works/pi-coding-agent";
8
8
  import { mkdirSync } from "node:fs";
9
9
  import { AGENT_DIR, AUTH_PATH, MODELS_PATH, sessionDir } from "./config";
10
+ import { createMemoryExtension, MEMORY_EDIT_TOOL_NAME, MEMORY_READ_TOOL_NAME } from "./memory";
10
11
  import { checkPathsForProject, loadSettings } from "./settings";
11
12
  import { identityExtension } from "./identity";
12
13
  import { setWritingStyle, writingStyleExtension } from "./writing-style";
@@ -34,7 +35,14 @@ import { prepareHeadlessStatsOutput, type HeadlessStatsOutput } from "./headless
34
35
  * are not constructed here, and subagent, trigger, and message-cache tools
35
36
  * need the running TUI for routing and notifications.
36
37
  */
37
- const HEADLESS_TOOL_NAMES = ["read", "write", "edit", "bash"];
38
+ const HEADLESS_TOOL_NAMES = [
39
+ "read",
40
+ "write",
41
+ "edit",
42
+ "bash",
43
+ MEMORY_READ_TOOL_NAME,
44
+ MEMORY_EDIT_TOOL_NAME,
45
+ ];
38
46
 
39
47
  /**
40
48
  * Handle one hosted web-search call from a headless run.
@@ -188,6 +196,7 @@ async function runPromptSession(
188
196
  checkModePromptExtension,
189
197
  checkModeExtension,
190
198
  sandboxController.extension(),
199
+ createMemoryExtension({ agentDir: AGENT_DIR, audience: "main" }),
191
200
  ],
192
201
  },
193
202
  });
@@ -4,14 +4,14 @@ import { PopupFrame } from "./popup-frame";
4
4
  type HelpGroup = { title: string; controls: [string, string][] };
5
5
 
6
6
  export const HELP_SUMMARY_WIDE = [
7
- "PUM workflow — prompt or steer · cache prompts · attach images · run managed worktree agents in parallel",
8
- "switch transcripts · merge successful agents · persist sessions · use Settings and safety checks",
7
+ "PUM workflow — prompt or steer · cache prompts · attach images · run managed agents in parallel",
8
+ "switch transcripts · close successful agents · optional worktrees · persistent sessions · safety settings",
9
9
  ] as const;
10
10
 
11
11
  export const HELP_SUMMARY = [
12
12
  "Prompt or steer. Cache prompts. Attach images.",
13
- "Run managed worktree agents in parallel.",
14
- "Switch transcripts and merge successful agents.",
13
+ "Run agents in parallel. Use worktrees for isolation.",
14
+ "Switch transcripts and close successful agents.",
15
15
  "Sessions persist. Settings include safety checks.",
16
16
  ] as const;
17
17
 
@@ -59,7 +59,7 @@ export const HELP_GROUPS: HelpGroup[] = [
59
59
  ["/clear", "Start a fresh session"],
60
60
  ["/goal", "Set or control a goal"],
61
61
  ["/goalf", "Work out a goal, then start it"],
62
- ["/background", "Start a managed agent for the selected transcript"],
62
+ ["/background", "Start a shared-project agent for the selected transcript"],
63
63
  ["/history", "Browse saved sessions"],
64
64
  ["/providers", "Add, edit, or delete a provider; /login adds"],
65
65
  ["/news", "Open recent answers (News)"],
package/src/main.tsx CHANGED
@@ -7,10 +7,11 @@ import {
7
7
  ModelRuntime,
8
8
  SessionManager,
9
9
  } from "@earendil-works/pi-coding-agent";
10
- import { mkdirSync } from "node:fs";
10
+ import { mkdirSync, writeSync } from "node:fs";
11
11
  import { randomUUID } from "node:crypto";
12
12
  import { App } from "./app";
13
13
  import { AGENT_DIR, AUTH_PATH, MODELS_PATH, sessionDir } from "./config";
14
+ import { createMemoryExtension } from "./memory";
14
15
  import { checkPathsForProject, loadSettings } from "./settings";
15
16
  import { setBashOutputSettingsIfPresent } from "./bash-output";
16
17
  import { installWebSearch, webSearch } from "./web-search";
@@ -49,6 +50,7 @@ import type { StartupOptions } from "./cli";
49
50
  import { TodoToolsController } from "./todo-tools";
50
51
  import { installSelectionClipboard } from "./clipboard";
51
52
  import { TerminalTitleController } from "./terminal-title";
53
+ import { isOrcaTerminal, OrcaStatusController } from "./orca-status";
52
54
  import { SandboxController } from "./sandbox";
53
55
  import {
54
56
  createFilesystemSandboxExtension,
@@ -260,6 +262,9 @@ export async function start(
260
262
  (_agentId, isReadonly) => sandboxController.extension({ readonly: isReadonly }),
261
263
  (_agentId, isReadonly) => createFilesystemSandboxExtension({ readonly: isReadonly }),
262
264
  ],
265
+ childWorkerExtensionFactories: [
266
+ () => createMemoryExtension({ agentDir: AGENT_DIR, audience: "subagent" }),
267
+ ],
263
268
  sandboxModeSource: () => sandboxController.mode,
264
269
  });
265
270
  const subagentExtension = subagentManager.mainExtension();
@@ -295,6 +300,7 @@ export async function start(
295
300
  filesystemSandboxExtension,
296
301
  mainCheckModeExtension,
297
302
  sandboxExtension,
303
+ createMemoryExtension({ agentDir: AGENT_DIR, audience: "main" }),
298
304
  questionnaireManager.extension({ id: "main", name: "main" }),
299
305
  mainToolGroups.extension(),
300
306
  mainTodoTools.extension(),
@@ -342,6 +348,11 @@ export async function start(
342
348
 
343
349
  const renderer = await createCliRenderer({ exitOnCtrlC: false });
344
350
  const terminalTitle = new TerminalTitleController((title) => renderer.setTerminalTitle(title));
351
+ const orcaStatus = new OrcaStatusController(
352
+ isOrcaTerminal(process.env),
353
+ // Keep the control message outside OpenTUI's external-output path.
354
+ (sequence) => { writeSync(process.stdout.fd, sequence); },
355
+ );
345
356
  const selectionClipboard = installSelectionClipboard(renderer);
346
357
  const root = createRoot(renderer);
347
358
  // Reported on the restored terminal by the exit action below, so the message
@@ -445,6 +456,7 @@ export async function start(
445
456
  spawnPreviewManager={spawnPreviewManager}
446
457
  messageCacheController={messageCacheController}
447
458
  terminalTitle={terminalTitle}
459
+ orcaStatus={orcaStatus}
448
460
  startupWarnings={[
449
461
  ...(sandboxWarning ? [sandboxWarning] : []),
450
462
  ...(outerSandbox ? [
@@ -0,0 +1,76 @@
1
+ import { createHash } from "node:crypto";
2
+ import { execFileSync } from "node:child_process";
3
+ import { posix, win32 } from "node:path";
4
+ import {
5
+ canonicalRealpathSync,
6
+ projectStorageKey,
7
+ type RuntimePlatform,
8
+ } from "./platform";
9
+
10
+ export type MemoryIdentity = {
11
+ key: string;
12
+ digest: string;
13
+ kind: "git" | "directory";
14
+ };
15
+
16
+ type MemoryIdentityOptions = {
17
+ platform?: RuntimePlatform;
18
+ gitPath?: (cwd: string, args: string[]) => string;
19
+ realpath?: (path: string, platform: RuntimePlatform) => string;
20
+ };
21
+
22
+ const resolvedIdentities = new Map<string, MemoryIdentity>();
23
+
24
+ function defaultGitPath(cwd: string, args: string[]): string {
25
+ return execFileSync("git", args, {
26
+ cwd,
27
+ encoding: "utf8",
28
+ maxBuffer: 4 * 1024 * 1024,
29
+ windowsHide: true,
30
+ stdio: ["ignore", "pipe", "ignore"],
31
+ }).replace(/\r?\n$/, "");
32
+ }
33
+
34
+ function digest(key: string): string {
35
+ return createHash("sha256").update(key).digest("hex");
36
+ }
37
+
38
+ /**
39
+ * Use Git's shared administrative directory as the repository identity.
40
+ * Every linked worktree reports the same directory, even when its checkout
41
+ * path is unrelated to the primary checkout path.
42
+ */
43
+ export function resolveMemoryIdentity(
44
+ cwd: string,
45
+ options: MemoryIdentityOptions = {},
46
+ ): MemoryIdentity {
47
+ const platform = options.platform ?? process.platform;
48
+ const paths = platform === "win32" ? win32 : posix;
49
+ const realpath = options.realpath ?? canonicalRealpathSync;
50
+ const gitPath = options.gitPath ?? defaultGitPath;
51
+ const cacheKey = `${platform}\0${projectStorageKey(cwd, platform)}`;
52
+ const cacheable = options.gitPath === undefined && options.realpath === undefined;
53
+ if (cacheable) {
54
+ const cached = resolvedIdentities.get(cacheKey);
55
+ if (cached) return cached;
56
+ }
57
+
58
+ try {
59
+ const raw = gitPath(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
60
+ const absolute = paths.isAbsolute(raw) ? raw : paths.resolve(cwd, raw);
61
+ const canonical = realpath(absolute, platform);
62
+ const key = `git:${projectStorageKey(canonical, platform)}`;
63
+ const identity: MemoryIdentity = { key, digest: digest(key), kind: "git" };
64
+ if (cacheable) resolvedIdentities.set(cacheKey, identity);
65
+ return identity;
66
+ } catch {
67
+ let canonical = paths.resolve(cwd);
68
+ try {
69
+ canonical = realpath(canonical, platform);
70
+ } catch { /* the session startup owns directory validation */ }
71
+ const key = `directory:${projectStorageKey(canonical, platform)}`;
72
+ const identity: MemoryIdentity = { key, digest: digest(key), kind: "directory" };
73
+ if (cacheable) resolvedIdentities.set(cacheKey, identity);
74
+ return identity;
75
+ }
76
+ }