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 +3 -2
- package/package.json +1 -1
- package/src/agent-selector.tsx +6 -3
- package/src/animation.tsx +62 -57
- package/src/app.tsx +29 -7
- package/src/commands.ts +1 -1
- package/src/headless.ts +10 -1
- package/src/help-popup.tsx +5 -5
- package/src/main.tsx +13 -1
- package/src/memory-identity.ts +76 -0
- package/src/memory.ts +327 -0
- package/src/orca-status.ts +80 -0
- package/src/prompt-cache.ts +21 -2
- package/src/replay.ts +12 -1
- package/src/stash-batch.ts +4 -3
- package/src/subagents/manager.ts +104 -34
- package/src/subagents/readonly.ts +1 -0
- package/src/subagents/spawn-preview-popup.tsx +4 -2
- package/src/subagents/types.ts +5 -3
- package/src/tool-groups.ts +10 -3
- package/src/tool-line.ts +5 -1
- package/src/transcript-output.ts +10 -6
- package/src/transcript.tsx +17 -1
- package/src/worktree.ts +2 -2
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
|
|
7
|
+
Plan, edit, run commands, review Markdown, and coordinate parallel subagents without leaving the TUI.
|
|
8
8
|
|
|
9
9
|
[](https://github.com/eugen1763/Pum/actions/workflows/ci.yml)
|
|
10
10
|
[](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
|
|
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
package/src/agent-selector.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
//
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
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?.
|
|
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
|
-
}, [
|
|
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
|
|
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
|
|
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
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 = [
|
|
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
|
});
|
package/src/help-popup.tsx
CHANGED
|
@@ -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
|
|
8
|
-
"switch transcripts ·
|
|
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
|
|
14
|
-
"Switch transcripts and
|
|
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
|
|
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
|
+
}
|