@aeondave/pi-persona 1.15.2
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/AGENTS.md +249 -0
- package/CHANGELOG.md +260 -0
- package/LICENSE +21 -0
- package/README.md +172 -0
- package/agents/balthasar.md +32 -0
- package/agents/casper.md +33 -0
- package/agents/evidence-verifier.md +23 -0
- package/agents/melchior.md +32 -0
- package/agents/operator.md +63 -0
- package/agents/research.md +58 -0
- package/agents/reviewer.md +49 -0
- package/agents/scout.md +23 -0
- package/agents/verifier.md +21 -0
- package/assets/banner.png +0 -0
- package/assets/demo1.gif +0 -0
- package/assets/demo1.mp4 +0 -0
- package/assets/workflow.png +0 -0
- package/contracts/finding.contract.json +9 -0
- package/docs/ARCHITECTURE.md +727 -0
- package/docs/EXPERIENCE.md +96 -0
- package/docs/MONITORS.md +84 -0
- package/docs/README.md +53 -0
- package/docs/REFERENCE.md +408 -0
- package/docs/SPINE.md +152 -0
- package/docs/STRATEGIES.md +215 -0
- package/docs/TELEMETRY.md +9 -0
- package/flows/deep-review.flow.json +8 -0
- package/flows/gated-build.flow.json +9 -0
- package/package.json +106 -0
- package/personas/audit.md +29 -0
- package/personas/dev.md +52 -0
- package/personas/elite.md +256 -0
- package/personas/judge.md +32 -0
- package/personas/magi.md +41 -0
- package/personas/planner.md +37 -0
- package/personas/researcher.md +37 -0
- package/personas/swarm.md +38 -0
- package/personas/verify.md +31 -0
- package/presets/magi-rounds.preset.json +5 -0
- package/prompts/spine.md +11 -0
- package/prompts/spine.worker.md +9 -0
- package/src/agents/agent.ts +78 -0
- package/src/bridge.ts +209 -0
- package/src/bus/broker/client.ts +379 -0
- package/src/bus/broker/framing.ts +43 -0
- package/src/bus/broker/host.ts +407 -0
- package/src/bus/broker/messages.ts +87 -0
- package/src/bus/broker/paths.ts +31 -0
- package/src/bus/contact.ts +145 -0
- package/src/bus/inproc.ts +279 -0
- package/src/bus/peers.ts +114 -0
- package/src/core/brief.ts +356 -0
- package/src/core/capabilities.ts +114 -0
- package/src/core/config.ts +228 -0
- package/src/core/contract.ts +391 -0
- package/src/core/data-root.ts +260 -0
- package/src/core/discovery.ts +33 -0
- package/src/core/display-label.ts +47 -0
- package/src/core/fence.ts +151 -0
- package/src/core/format.ts +13 -0
- package/src/core/frontmatter.ts +202 -0
- package/src/core/models.ts +192 -0
- package/src/core/monitor.ts +415 -0
- package/src/core/naming.ts +42 -0
- package/src/core/nudge.ts +238 -0
- package/src/core/ownership.ts +102 -0
- package/src/core/permissions.ts +56 -0
- package/src/core/pi-compat.ts +50 -0
- package/src/core/seed.ts +770 -0
- package/src/core/session-identity.ts +89 -0
- package/src/core/time.ts +202 -0
- package/src/core/timer.ts +221 -0
- package/src/core/types.ts +28 -0
- package/src/engine/adapter.ts +257 -0
- package/src/engine/async.ts +898 -0
- package/src/engine/change-report.ts +143 -0
- package/src/engine/child.ts +532 -0
- package/src/engine/errors.ts +30 -0
- package/src/engine/fallback.ts +57 -0
- package/src/engine/handles.ts +21 -0
- package/src/engine/inproc.ts +857 -0
- package/src/engine/signals.ts +11 -0
- package/src/engine/spec-preflight.ts +33 -0
- package/src/engine/stream.ts +369 -0
- package/src/engine/worktree.ts +144 -0
- package/src/exocom/activation.ts +31 -0
- package/src/exocom/codes.ts +261 -0
- package/src/exocom/envelope.ts +180 -0
- package/src/exocom/gate.ts +6 -0
- package/src/exocom/guards.ts +45 -0
- package/src/exocom/inbound.ts +141 -0
- package/src/exocom/install.ts +970 -0
- package/src/exocom/ledger.ts +603 -0
- package/src/exocom/limits.ts +20 -0
- package/src/exocom/paths.ts +35 -0
- package/src/exocom/plane.ts +1116 -0
- package/src/exocom/registry.ts +307 -0
- package/src/exocom/scope.ts +42 -0
- package/src/exocom/status.ts +86 -0
- package/src/exocom/untrusted.ts +40 -0
- package/src/exocom/wait.ts +23 -0
- package/src/exocom/write-guard.ts +89 -0
- package/src/extension/broker-host.ts +181 -0
- package/src/extension/clock.ts +14 -0
- package/src/extension/engine.ts +321 -0
- package/src/extension/hooks.ts +796 -0
- package/src/extension/identity.ts +244 -0
- package/src/extension/shared.ts +175 -0
- package/src/extension.ts +2221 -0
- package/src/loader.ts +218 -0
- package/src/monitor/process.ts +116 -0
- package/src/monitor/session.ts +61 -0
- package/src/orchestration/flow-journal.ts +86 -0
- package/src/orchestration/flow-run.ts +301 -0
- package/src/orchestration/flow.ts +125 -0
- package/src/orchestration/judge.ts +78 -0
- package/src/orchestration/model-retry.ts +85 -0
- package/src/orchestration/parallel.ts +107 -0
- package/src/orchestration/params.ts +23 -0
- package/src/orchestration/reducers.ts +123 -0
- package/src/orchestration/render.ts +127 -0
- package/src/orchestration/roster.ts +133 -0
- package/src/orchestration/sdk.ts +326 -0
- package/src/orchestration/strategies/compete.ts +182 -0
- package/src/orchestration/strategies/council-rounds.ts +124 -0
- package/src/orchestration/strategies/critic-loop.ts +168 -0
- package/src/orchestration/strategies/debate.ts +126 -0
- package/src/orchestration/strategies/fanout.ts +20 -0
- package/src/orchestration/strategies/judge.ts +117 -0
- package/src/orchestration/strategies/magi.ts +239 -0
- package/src/orchestration/strategies/map.ts +327 -0
- package/src/orchestration/strategies/pair.ts +72 -0
- package/src/orchestration/strategies/pipeline.ts +91 -0
- package/src/orchestration/strategies/synthesize.ts +99 -0
- package/src/orchestration/strategy.ts +49 -0
- package/src/orchestration/types.ts +31 -0
- package/src/orchestration/voting.ts +126 -0
- package/src/persona/config-store.ts +215 -0
- package/src/persona/controller.ts +173 -0
- package/src/persona/gating.ts +82 -0
- package/src/persona/orchestrate.ts +83 -0
- package/src/persona/persona.ts +294 -0
- package/src/persona/spine.ts +221 -0
- package/src/persona/state.ts +28 -0
- package/src/telemetry/contract.ts +244 -0
- package/src/telemetry/paths.ts +14 -0
- package/src/telemetry/producer.ts +390 -0
- package/src/tools/council.ts +200 -0
- package/src/tools/delegate-tool.ts +637 -0
- package/src/tools/delegate.ts +661 -0
- package/src/tools/exocom-work.ts +328 -0
- package/src/tools/exocom.ts +476 -0
- package/src/tools/flow.ts +117 -0
- package/src/tools/intercom-tool.ts +292 -0
- package/src/tools/intercom.ts +181 -0
- package/src/tools/models.ts +39 -0
- package/src/tools/monitor.ts +66 -0
- package/src/tools/timer.ts +105 -0
- package/src/ui/agent-overlay.ts +526 -0
- package/src/ui/agent-tree.ts +307 -0
- package/src/ui/live-clock.ts +42 -0
- package/src/ui/model-picker.ts +160 -0
- package/src/ui/presentation.ts +170 -0
- package/src/ui/usage.ts +133 -0
- package/teams.yaml +27 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-time relocation of the plugin's storage root inside the Pi agent dir.
|
|
3
|
+
*
|
|
4
|
+
* `pi-persona` is the npm PACKAGE name; the directory a user's data lives in is `persona` (the
|
|
5
|
+
* same root `personaDataDir()` has always used for agents/config/state). Exocom and the POSIX
|
|
6
|
+
* broker socket wrote their files under a second, package-named directory, so an install ended up
|
|
7
|
+
* with `~/.pi/agent/pi-persona` next to `~/.pi/agent/persona` — two roots for one plugin. Moving
|
|
8
|
+
* those writers is a one-line change; carrying the existing files across is this module.
|
|
9
|
+
*
|
|
10
|
+
* What is actually at stake is small but not reconstructible: registry entries are heartbeat-scoped
|
|
11
|
+
* and a restarted Pi rewrites them, but `exocom/codes/` holds the durable cross-workspace join
|
|
12
|
+
* aliases (and their `.used` reservations), which exist nowhere else.
|
|
13
|
+
*
|
|
14
|
+
* The migration therefore behaves like a careful merge rather than a rename:
|
|
15
|
+
* - it never overwrites a name the current root already has (the destination copy wins),
|
|
16
|
+
* - it never removes a source it did not successfully place at the destination,
|
|
17
|
+
* - it places nothing THROUGH a link below the current root, so a planted (or stale) junction
|
|
18
|
+
* cannot carry the tree out of the agent dir — the root itself is exempt, since that is the
|
|
19
|
+
* link `personaDataDir()` and every other writer already follow,
|
|
20
|
+
* - each file is placed by hard-link-then-unlink, so an INTERRUPTION leaves the bytes reachable
|
|
21
|
+
* under BOTH names and the next run finishes the move instead of losing it; an OBSERVED unlink
|
|
22
|
+
* failure is different — this run undoes the link it just made, because exocom reads `nlink > 1`
|
|
23
|
+
* as tampering, and it never undoes a placement an earlier run completed,
|
|
24
|
+
* - every failure is a per-entry warning, so one locked file cannot abort the rest — or, since
|
|
25
|
+
* this runs during activation, take the session down with it.
|
|
26
|
+
*
|
|
27
|
+
* Pure over node:fs (no Pi imports), so the whole policy is unit-tested against temp dirs.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { constants, copyFileSync, type Dirent, existsSync, linkSync, lstatSync, mkdirSync, readdirSync, rmdirSync, unlinkSync } from "node:fs";
|
|
31
|
+
import { join } from "node:path";
|
|
32
|
+
|
|
33
|
+
/** The package-named root written by ≤1.13.1, and the one root everything uses now. */
|
|
34
|
+
const LEGACY_ROOT = "pi-persona";
|
|
35
|
+
const CURRENT_ROOT = "persona";
|
|
36
|
+
/** The legacy tree is at most `exocom/codes/.used/<file>` deep. The cap is a backstop against a
|
|
37
|
+
* surprising tree, not an expected shape — nothing here follows a link, so it cannot loop. */
|
|
38
|
+
const MAX_DEPTH = 8;
|
|
39
|
+
|
|
40
|
+
export interface DataRootMigrationResult {
|
|
41
|
+
/** Absolute paths under the current root now holding what the legacy root held. */
|
|
42
|
+
moved: string[];
|
|
43
|
+
/** Absolute legacy paths deliberately left alone: the current root already owns that name, the
|
|
44
|
+
* entry is not a regular file (a link, or a live/stale broker socket), or a foreign handle held
|
|
45
|
+
* the source open so this run undid its own placement rather than leave a second name behind. */
|
|
46
|
+
kept: string[];
|
|
47
|
+
warnings: string[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Per-call filesystem seam. Production leaves it empty; a test can prove the copy fallback that a
|
|
51
|
+
* filesystem without hard links (exFAT, some network mounts) takes without one, and the removal
|
|
52
|
+
* failures (a foreign handle, a peer racing us for the same name) that only a loaded host hits. */
|
|
53
|
+
export interface DataRootMigrationIO {
|
|
54
|
+
link?: (from: string, to: string) => void;
|
|
55
|
+
unlink?: (path: string) => void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface DataRootMigrationOptions {
|
|
59
|
+
io?: DataRootMigrationIO;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type ResolvedIO = Required<DataRootMigrationIO>;
|
|
63
|
+
|
|
64
|
+
function errorText(error: unknown): string {
|
|
65
|
+
return error instanceof Error ? error.message : String(error);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isMissing(error: unknown): boolean {
|
|
69
|
+
return (error as NodeJS.ErrnoException).code === "ENOENT";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** True only when both names are the SAME regular file — i.e. our own link() landed and the
|
|
73
|
+
* unlink() after it did not. Deliberately conservative: a filesystem that reports no inode or no
|
|
74
|
+
* extra link answers false, and the caller then keeps both copies. Guessing wrong in the other
|
|
75
|
+
* direction would delete a user file that was never copied. */
|
|
76
|
+
function isSameFile(source: string, target: string): boolean {
|
|
77
|
+
try {
|
|
78
|
+
const from = lstatSync(source);
|
|
79
|
+
const to = lstatSync(target);
|
|
80
|
+
return from.isFile() && to.isFile() && from.ino !== 0 && from.nlink > 1 && from.dev === to.dev && from.ino === to.ino;
|
|
81
|
+
} catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Destination-side counterpart to the Dirent lstat semantics used on the source: a link is never a
|
|
87
|
+
* directory we place anything into. Same rule as `ensureDirectory` in exocom/codes.ts. */
|
|
88
|
+
function isRealDirectory(path: string): boolean {
|
|
89
|
+
try {
|
|
90
|
+
const stat = lstatSync(path);
|
|
91
|
+
return stat.isDirectory() && !stat.isSymbolicLink();
|
|
92
|
+
} catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Place `source` at `target` without clobbering, for a filesystem that has no hard links. */
|
|
98
|
+
function copyAcross(source: string, target: string, result: DataRootMigrationResult): boolean {
|
|
99
|
+
try {
|
|
100
|
+
copyFileSync(source, target, constants.COPYFILE_EXCL);
|
|
101
|
+
return true;
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
|
104
|
+
// Only a source still on disk is a name we are keeping; a peer that completed this move
|
|
105
|
+
// left nothing here, and reported the entry itself.
|
|
106
|
+
if (existsSync(source)) result.kept.push(source);
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
if (isMissing(error)) return false; // another migrator moved it out from under us
|
|
110
|
+
result.warnings.push(`data root migration could not copy ${source}: ${errorText(error)}`);
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The source outlived the placement that should have consumed it — a foreign handle holds it open
|
|
116
|
+
* (EBUSY/EPERM). Keeping both names is worse than doing nothing for the very files this migration
|
|
117
|
+
* exists to carry: exocom reads `nlink > 1` as tampering and refuses the ledger outright (see
|
|
118
|
+
* `assertSafeStat` in exocom/ledger.ts), so a "harmless" second name silently disables it. Undo the
|
|
119
|
+
* link we just made — provably ours, because it is the same inode — and report the entry as
|
|
120
|
+
* untouched, leaving the exact pre-run state for the next run to retry. A copy is a distinct file
|
|
121
|
+
* the destination can use on its own, so that one stays and the next run resolves the pair. */
|
|
122
|
+
function unwindPlacement(source: string, target: string, reason: string, io: ResolvedIO, result: DataRootMigrationResult): void {
|
|
123
|
+
// Callers must only reach here for a link THIS call created; see `placedNow` in `moveFile`.
|
|
124
|
+
if (isSameFile(source, target)) {
|
|
125
|
+
try {
|
|
126
|
+
io.unlink(target);
|
|
127
|
+
result.kept.push(source);
|
|
128
|
+
result.warnings.push(`data root migration left ${source} in place: ${reason}`);
|
|
129
|
+
} catch {
|
|
130
|
+
result.warnings.push(`data root migration placed ${target} but could not remove ${source}: ${reason} — ${target} is unusable to exocom until ${source} is removed`);
|
|
131
|
+
}
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
result.warnings.push(`data root migration placed ${target} but could not remove ${source}: ${reason}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function moveFile(source: string, target: string, io: ResolvedIO, result: DataRootMigrationResult): void {
|
|
138
|
+
// Only a link THIS call created may be undone later: the EEXIST/same-inode branch below resumes a
|
|
139
|
+
// placement an earlier run already completed, and unwinding that would destroy work that is done
|
|
140
|
+
// and leave the destination empty.
|
|
141
|
+
let placedNow = true;
|
|
142
|
+
try {
|
|
143
|
+
// link() is atomic and no-clobber: a concurrent writer that already owns the destination
|
|
144
|
+
// name fails us with EEXIST rather than having its bytes replaced.
|
|
145
|
+
io.link(source, target);
|
|
146
|
+
} catch (error) {
|
|
147
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
148
|
+
if (code === "ENOENT") return; // a concurrent migrator already moved this entry
|
|
149
|
+
if (code === "EEXIST") {
|
|
150
|
+
if (!isSameFile(source, target)) {
|
|
151
|
+
// A collision only while the source is still there; otherwise a peer finished this move.
|
|
152
|
+
if (existsSync(source)) result.kept.push(source);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
// Same inode: finish the interrupted move below instead of stranding the legacy name.
|
|
156
|
+
placedNow = false;
|
|
157
|
+
} else if (!copyAcross(source, target, result)) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
io.unlink(source);
|
|
163
|
+
} catch (error) {
|
|
164
|
+
// Two processes removing the same name hand the loser EPERM on Windows rather than ENOENT, so
|
|
165
|
+
// the errno alone cannot tell "still here" from "a peer got there first" — but the source can,
|
|
166
|
+
// and a source that is gone was migrated, not stranded.
|
|
167
|
+
if (!isMissing(error) && existsSync(source)) {
|
|
168
|
+
if (placedNow) unwindPlacement(source, target, errorText(error), io, result);
|
|
169
|
+
else result.warnings.push(`data root migration could not remove ${source}: ${errorText(error)} — ${target} already holds it`);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
result.moved.push(target);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function mergeDirectory(from: string, to: string, depth: number, io: ResolvedIO, result: DataRootMigrationResult): void {
|
|
177
|
+
if (depth > MAX_DEPTH) {
|
|
178
|
+
result.warnings.push(`data root migration stopped at an unexpectedly deep path: ${from}`);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
let entries: Dirent[];
|
|
182
|
+
try {
|
|
183
|
+
entries = readdirSync(from, { withFileTypes: true });
|
|
184
|
+
} catch (error) {
|
|
185
|
+
if (!isMissing(error)) result.warnings.push(`data root migration could not read ${from}: ${errorText(error)}`);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (entries.length === 0) return; // nothing to place; the caller removes the empty directory
|
|
189
|
+
try {
|
|
190
|
+
mkdirSync(to, { recursive: true, mode: 0o700 });
|
|
191
|
+
} catch (error) {
|
|
192
|
+
result.warnings.push(`data root migration could not create ${to}: ${errorText(error)}`);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
// mkdir SUCCEEDS on a name that is already a link to a directory, and every placement below
|
|
196
|
+
// would then write THROUGH it — out of the agent dir — before unlinking the legacy original.
|
|
197
|
+
// Depth 0 is the current root itself, where a link means the user relocated their data dir and
|
|
198
|
+
// following it is correct: `personaDataDir()` and exocom/codes.ts follow that same link.
|
|
199
|
+
if (depth > 0 && !isRealDirectory(to)) {
|
|
200
|
+
result.warnings.push(`data root migration left ${from} untouched: ${to} is not a directory`);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
for (const entry of entries) {
|
|
204
|
+
const source = join(from, entry.name);
|
|
205
|
+
const target = join(to, entry.name);
|
|
206
|
+
// Dirents carry lstat semantics, so a symlinked directory is NOT a directory here and falls
|
|
207
|
+
// to the non-regular branch below with everything else we refuse to follow.
|
|
208
|
+
if (entry.isDirectory()) {
|
|
209
|
+
mergeDirectory(source, target, depth + 1, io, result);
|
|
210
|
+
try {
|
|
211
|
+
rmdirSync(source);
|
|
212
|
+
} catch {
|
|
213
|
+
// Non-empty (it still holds entries we kept) or already gone. Either way it is not
|
|
214
|
+
// ours to force: rmdir is the only removal here that can never take user data.
|
|
215
|
+
}
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (!entry.isFile()) {
|
|
219
|
+
// A POSIX broker socket is a runtime rendezvous artifact, not user data: it cannot be
|
|
220
|
+
// copied, and the new endpoint path is derived fresh on the next session. Leave it, and
|
|
221
|
+
// leave anything else exotic, exactly where it is.
|
|
222
|
+
result.kept.push(source);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
moveFile(source, target, io, result);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Merge `<agentDir>/pi-persona` into `<agentDir>/persona`, then remove the legacy root once it is
|
|
231
|
+
* empty. A missing legacy root (a fresh install, or an install already migrated) is a no-op, so
|
|
232
|
+
* this is safe to call on every activation, from several Pi processes at once, and to interrupt.
|
|
233
|
+
* It never throws for a per-entry race or failure — the caller reports {@link
|
|
234
|
+
* DataRootMigrationResult.warnings} instead of losing the session to them.
|
|
235
|
+
*/
|
|
236
|
+
export function migrateLegacyDataRoot(agentDir: string, options: DataRootMigrationOptions = {}): DataRootMigrationResult {
|
|
237
|
+
const io: ResolvedIO = { link: options.io?.link ?? linkSync, unlink: options.io?.unlink ?? unlinkSync };
|
|
238
|
+
const legacy = join(agentDir, LEGACY_ROOT);
|
|
239
|
+
const result: DataRootMigrationResult = { moved: [], kept: [], warnings: [] };
|
|
240
|
+
let root;
|
|
241
|
+
try {
|
|
242
|
+
root = lstatSync(legacy);
|
|
243
|
+
} catch (error) {
|
|
244
|
+
if (!isMissing(error)) result.warnings.push(`data root migration could not inspect ${legacy}: ${errorText(error)}`);
|
|
245
|
+
return result;
|
|
246
|
+
}
|
|
247
|
+
if (!root.isDirectory() || root.isSymbolicLink()) {
|
|
248
|
+
result.warnings.push(`data root migration left an unexpected ${legacy} untouched`);
|
|
249
|
+
return result;
|
|
250
|
+
}
|
|
251
|
+
mergeDirectory(legacy, join(agentDir, CURRENT_ROOT), 0, io, result);
|
|
252
|
+
try {
|
|
253
|
+
// The point of the whole exercise: the agent dir ends with one directory per plugin. rmdir
|
|
254
|
+
// only succeeds on the empty root, so a kept collision preserves it (and this run's report).
|
|
255
|
+
rmdirSync(legacy);
|
|
256
|
+
} catch {
|
|
257
|
+
// Still holds entries we deliberately kept, or another process removed it first.
|
|
258
|
+
}
|
|
259
|
+
return result;
|
|
260
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resource discovery precedence/shadowing — the pure merge logic.
|
|
3
|
+
*
|
|
4
|
+
* Pure module. Filesystem listing lives in a thin adapter; this resolves the
|
|
5
|
+
* precedence (builtin < user < project, later wins) and records every shadowed
|
|
6
|
+
* loser so `/doctor` can explain exactly which file won and what it hid.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface DiscoveredFile {
|
|
10
|
+
name: string;
|
|
11
|
+
path: string;
|
|
12
|
+
scope: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface MergeResult {
|
|
16
|
+
resolved: DiscoveredFile[];
|
|
17
|
+
/** Files that were overridden by a higher-precedence same-named file. */
|
|
18
|
+
shadowed: DiscoveredFile[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Merge discovery layers given in increasing precedence order (later wins). */
|
|
22
|
+
export function mergeByPrecedence(layers: DiscoveredFile[][]): MergeResult {
|
|
23
|
+
const winner = new Map<string, DiscoveredFile>();
|
|
24
|
+
const shadowed: DiscoveredFile[] = [];
|
|
25
|
+
for (const layer of layers) {
|
|
26
|
+
for (const file of layer) {
|
|
27
|
+
const prev = winner.get(file.name);
|
|
28
|
+
if (prev) shadowed.push(prev);
|
|
29
|
+
winner.set(file.name, file);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return { resolved: [...winner.values()], shadowed };
|
|
33
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { stripTerminalControls } from "./fence.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Make an untrusted name safe where pi-persona needs an identifier-like label.
|
|
5
|
+
*
|
|
6
|
+
* Routing and attribution labels are metadata, not instructions: keep them to one
|
|
7
|
+
* bounded line and reduce punctuation/whitespace to identifier-like separators before
|
|
8
|
+
* interpolating them outside an untrusted-output fence. Human-facing persona status uses
|
|
9
|
+
* `sanitizeStatusLabel` below so safe presentation such as emoji is not discarded.
|
|
10
|
+
*/
|
|
11
|
+
export const MAX_DISPLAY_LABEL_CHARS = 80;
|
|
12
|
+
|
|
13
|
+
const unsafeLabelCharacters = /[^\p{L}\p{N}._@+#-]+/gu;
|
|
14
|
+
const edgeSeparators = /^[-._@+#]+|[-._@+#]+$/g;
|
|
15
|
+
|
|
16
|
+
export function sanitizeDisplayLabel(value: string, fallback = "agent", maxChars = MAX_DISPLAY_LABEL_CHARS): string {
|
|
17
|
+
const limit = Number.isFinite(maxChars) ? Math.max(1, Math.floor(maxChars)) : MAX_DISPLAY_LABEL_CHARS;
|
|
18
|
+
const normalize = (input: string): string =>
|
|
19
|
+
input
|
|
20
|
+
.normalize("NFKC")
|
|
21
|
+
.replace(unsafeLabelCharacters, "-")
|
|
22
|
+
.replace(/-{2,}/g, "-")
|
|
23
|
+
.replace(edgeSeparators, "")
|
|
24
|
+
.slice(0, limit);
|
|
25
|
+
const safe = normalize(value);
|
|
26
|
+
if (safe) return safe;
|
|
27
|
+
return normalize(fallback) || "agent";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function clipCodeUnits(text: string, units: number): string {
|
|
31
|
+
if (text.length <= units) return text;
|
|
32
|
+
const last = text.charCodeAt(units - 1);
|
|
33
|
+
return text.slice(0, last >= 0xd800 && last <= 0xdbff ? units - 1 : units);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Sanitize a human-facing persona label without reducing it to a protocol identifier.
|
|
38
|
+
* Emoji, spaces, and ordinary punctuation are presentation, not control syntax; preserve
|
|
39
|
+
* them after removing terminal sequences and folding the value to one bounded line.
|
|
40
|
+
*/
|
|
41
|
+
export function sanitizeStatusLabel(value: string, fallback = "agent", maxChars = MAX_DISPLAY_LABEL_CHARS): string {
|
|
42
|
+
const limit = Number.isFinite(maxChars) ? Math.max(1, Math.floor(maxChars)) : MAX_DISPLAY_LABEL_CHARS;
|
|
43
|
+
const normalize = (input: string): string => stripTerminalControls(input).normalize("NFKC").replace(/\s+/gu, " ").trim();
|
|
44
|
+
const readable = normalize(value) || normalize(fallback) || "agent";
|
|
45
|
+
if (readable.length <= limit) return readable;
|
|
46
|
+
return `${clipCodeUnits(readable, limit - 1)}…`;
|
|
47
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fencing for UNTRUSTED text from other agents (guardrails: sub-agent output, and exocom peer
|
|
3
|
+
* messages, must never reach another agent's context unfenced — prompt-injection defense). Pure
|
|
4
|
+
* module so the extension (supervisor-bound results/follow-ups) and the in-process engine's
|
|
5
|
+
* delivery bridge (peer/supervisor messages steered into a child) share ONE fence per flavor.
|
|
6
|
+
* Two flavors, kept distinct on purpose (sub-agent vs. exocom peer are different trust
|
|
7
|
+
* relationships and should read that way): `fenceUntrusted`/`attributeInbound` for sub-agents,
|
|
8
|
+
* `fencePeer`/`attributePeer` for exocom peers.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const OSC_MAX_PAYLOAD = 4096;
|
|
12
|
+
const CSI_MAX_BODY = 270;
|
|
13
|
+
|
|
14
|
+
function isCsiFinal(code: number): boolean {
|
|
15
|
+
return code >= 0x40 && code <= 0x7e;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isCsiParameter(code: number): boolean {
|
|
19
|
+
return code >= 0x30 && code <= 0x3f;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isCsiIntermediate(code: number): boolean {
|
|
23
|
+
return code >= 0x20 && code <= 0x2f;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Strip terminal controls with a single bounded scan. In particular, an unterminated OSC
|
|
28
|
+
* must not use a greedy `[^BEL]*` expression: a report containing many `ESC ]` introducers
|
|
29
|
+
* would make the regex rescan the remaining report from every introducer (quadratic work on
|
|
30
|
+
* the synchronous supervisor path).
|
|
31
|
+
*/
|
|
32
|
+
export function stripTerminalControls(text: string): string {
|
|
33
|
+
const out: string[] = [];
|
|
34
|
+
for (let i = 0; i < text.length;) {
|
|
35
|
+
const code = text.charCodeAt(i);
|
|
36
|
+
if (code === 0x0d) {
|
|
37
|
+
out.push("\n");
|
|
38
|
+
i += text.charCodeAt(i + 1) === 0x0a ? 2 : 1;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (code === 0x0a) {
|
|
42
|
+
out.push("\n");
|
|
43
|
+
i += 1;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (code === 0x1b) {
|
|
47
|
+
const next = text.charCodeAt(i + 1);
|
|
48
|
+
if (next === 0x5d) { // OSC: ESC ] ... BEL or ST
|
|
49
|
+
let j = i + 2;
|
|
50
|
+
let payload = 0;
|
|
51
|
+
let terminated = false;
|
|
52
|
+
while (j < text.length && payload < OSC_MAX_PAYLOAD) {
|
|
53
|
+
const body = text.charCodeAt(j);
|
|
54
|
+
if (body === 0x07) {
|
|
55
|
+
j += 1;
|
|
56
|
+
terminated = true;
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
if (body === 0x1b) {
|
|
60
|
+
if (text.charCodeAt(j + 1) === 0x5c) {
|
|
61
|
+
j += 2;
|
|
62
|
+
terminated = true;
|
|
63
|
+
}
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
payload += 1;
|
|
67
|
+
j += 1;
|
|
68
|
+
}
|
|
69
|
+
if (terminated) {
|
|
70
|
+
i = j;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
// Bounded or interrupted OSC: discard only its introducer. Its body is
|
|
74
|
+
// ordinary visible data, and any later ESC is handled by this same scan.
|
|
75
|
+
i += 2;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (next === 0x5b) { // CSI: consume a bounded parameter/intermediate run + final
|
|
79
|
+
let j = i + 2;
|
|
80
|
+
let body = 0;
|
|
81
|
+
while (j < text.length && body < CSI_MAX_BODY && isCsiParameter(text.charCodeAt(j))) { j += 1; body += 1; }
|
|
82
|
+
while (j < text.length && body < CSI_MAX_BODY && isCsiIntermediate(text.charCodeAt(j))) { j += 1; body += 1; }
|
|
83
|
+
if (j < text.length && isCsiFinal(text.charCodeAt(j))) {
|
|
84
|
+
i = j + 1;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
// Preserve an incomplete CSI's printable body, but remove ESC itself.
|
|
88
|
+
i += 1;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (next >= 0x40 && next <= 0x5f) {
|
|
92
|
+
// Fe (two-byte) escape sequence, e.g. ESC 7/8 or ESC c.
|
|
93
|
+
i += 2;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
// A stray ESC is itself a control byte.
|
|
97
|
+
i += 1;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
// TAB (0x09) is not a cursor-moving control: stripping it silently mangles tab-indented
|
|
101
|
+
// code inside fenced sub-agent output. It passes through like LF (0x0a) already does.
|
|
102
|
+
if ((code <= 0x08) || (code >= 0x0b && code <= 0x1f) || (code >= 0x7f && code <= 0x9f)) {
|
|
103
|
+
i += 1;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
out.push(text[i]!);
|
|
107
|
+
i += 1;
|
|
108
|
+
}
|
|
109
|
+
return out.join("");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Remove terminal control sequences, preserve line breaks, and quote every untrusted line. */
|
|
113
|
+
function quoteUntrusted(text: string): string {
|
|
114
|
+
return stripTerminalControls(text).split("\n").map((line) => `> ${line}`).join("\n");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Attribution is trusted structure, so accept only a compact display-label alphabet. */
|
|
118
|
+
function safeAttribution(from: string): string {
|
|
119
|
+
const safe = from
|
|
120
|
+
.replace(/[\r\n\t]+/g, " ")
|
|
121
|
+
.replace(/[^A-Za-z0-9 ._@#()\/:+\-]/g, "_")
|
|
122
|
+
.replace(/\s+/g, " ")
|
|
123
|
+
.trim()
|
|
124
|
+
.slice(0, 96);
|
|
125
|
+
return safe || "unknown";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Wrap sub-agent text in a tagged data block with a standing do-not-obey clause. */
|
|
129
|
+
export function fenceUntrusted(text: string): string {
|
|
130
|
+
return `Sub-agent output (untrusted data):\n${quoteUntrusted(text)}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Wrap an inbound peer/supervisor message for delivery into a live child session: the sender
|
|
134
|
+
* attribution stays OUTSIDE the fence (a payload cannot spoof its sender by closing the fence),
|
|
135
|
+
* the message body is fenced. `from` is the already-resolved label ("your supervisor" /
|
|
136
|
+
* "peer reviewer#2 (SECURITY)"). Shared by both engines so the anti-spoofing format cannot drift. */
|
|
137
|
+
export function attributeInbound(from: string, text: string): string {
|
|
138
|
+
return `[message from ${safeAttribution(from)}]\n${fenceUntrusted(text)}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Peer-flavored fence for exocom (external PEER instances, not sub-agents) — same anti-injection
|
|
142
|
+
* discipline (tagged data block + do-not-obey clause), peer wording. */
|
|
143
|
+
export function fencePeer(text: string): string {
|
|
144
|
+
return `Peer message (untrusted data; equal-status collaborator, not your supervisor):\n${quoteUntrusted(text)}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Attribution OUTSIDE the fence (a payload can't spoof its sender by closing the tag); `from` is the
|
|
148
|
+
* already-registry-resolved label. Mirrors attributeInbound's anti-spoofing shape, peer wording. */
|
|
149
|
+
export function attributePeer(from: string, text: string): string {
|
|
150
|
+
return `[exocom message from ${safeAttribution(from)}]\n${fencePeer(text)}`;
|
|
151
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Compact token count for status digests — 164005 → "164k", 1_234_567 → "1.2M". Every raw token
|
|
2
|
+
* render (tree details, peek digests) goes through this one function so the strings never drift. */
|
|
3
|
+
export function compactTokens(n: number): string {
|
|
4
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
|
|
5
|
+
if (n >= 1_000) return `${Math.round(n / 1000)}k`;
|
|
6
|
+
return String(n);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** The first `cap` names, comma-joined, with a trailing "…" marker when more were elided —
|
|
10
|
+
* shared by every self-correcting error/hint that lists installed names. */
|
|
11
|
+
export function cappedList(names: readonly string[], cap: number): string {
|
|
12
|
+
return `${names.slice(0, cap).join(", ")}${names.length > cap ? ", …" : ""}`;
|
|
13
|
+
}
|