pi-umbra-subagents 0.1.0
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/LICENSE +21 -0
- package/README.md +88 -0
- package/extensions/umbra-loop.ts +102 -0
- package/extensions/umbra-subagents/bar/bar-line.ts +344 -0
- package/extensions/umbra-subagents/bar.check.ts +229 -0
- package/extensions/umbra-subagents/bar.ts +173 -0
- package/extensions/umbra-subagents/fan/index.ts +110 -0
- package/extensions/umbra-subagents/fan/spec.ts +81 -0
- package/extensions/umbra-subagents/fan/store.check.ts +140 -0
- package/extensions/umbra-subagents/fan/store.ts +491 -0
- package/extensions/umbra-subagents/panel.check.ts +193 -0
- package/extensions/umbra-subagents/panel.ts +333 -0
- package/extensions/umbra-subagents/skills/delegate/SKILL.md +95 -0
- package/extensions/umbra-subagents/skills/delegate/beacon.ts +210 -0
- package/extensions/umbra-subagents/skills/delegate/delegate.env +12 -0
- package/extensions/umbra-subagents/skills/delegate/report.md +15 -0
- package/extensions/umbra-subagents/skills/delegate/run.check.sh +120 -0
- package/extensions/umbra-subagents/skills/delegate/run.sh +170 -0
- package/extensions/umbra-subagents/skills/delegate/state.check.ts +137 -0
- package/extensions/umbra-subagents/skills/delegate/state.ts +295 -0
- package/extensions/umbra-subagents/skills/fan/SKILL.md +52 -0
- package/extensions/umbra-subagents.ts +4 -0
- package/package.json +43 -0
- package/patch.mjs +97 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// The only thing a headless `pi -p` branch says about itself while it is still running.
|
|
2
|
+
// The delegate skill and the fan extension both seed one JSON file per branch before
|
|
3
|
+
// launching it, hand the path over in PI_BRANCH_STATE, and load this file with `-e`; from
|
|
4
|
+
// that moment this is the file's sole writer. It exists because a branch's report lands only
|
|
5
|
+
// when the branch is over, and the panel needs a sentence a great deal earlier than that.
|
|
6
|
+
//
|
|
7
|
+
// It is never auto-loaded: it lives in the skill folder, not in ~/.pi/agent/extensions/, so
|
|
8
|
+
// the interactive session's extension set is byte-identical with and without it. Nothing
|
|
9
|
+
// here registers a tool, which is what makes the whole feature cost zero prompt tokens.
|
|
10
|
+
|
|
11
|
+
import { readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { basename } from "node:path";
|
|
13
|
+
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
14
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import type { BranchState } from "./state.ts";
|
|
16
|
+
|
|
17
|
+
// A regex would let a runaway pattern eat the whole row; these two caps keep the column
|
|
18
|
+
// width predictable without truncating the part that identifies the file.
|
|
19
|
+
const ARG_W = 32;
|
|
20
|
+
const LINE_W = 72;
|
|
21
|
+
// Tool events arrive in bursts — two `start`s and an `end` inside one animation frame is
|
|
22
|
+
// ordinary — and each write is a tmp file plus a rename. Coalescing to one write per 100 ms
|
|
23
|
+
// stays well under the panel's own tick, so nothing is ever late on screen.
|
|
24
|
+
const WRITE_MS = 100;
|
|
25
|
+
|
|
26
|
+
const cut = (text: string, width: number) => (text.length <= width ? text : `${text.slice(0, width - 1)}…`);
|
|
27
|
+
|
|
28
|
+
const str = (value: unknown): string => (typeof value === "string" ? value.trim() : "");
|
|
29
|
+
|
|
30
|
+
const file = (value: unknown): string => {
|
|
31
|
+
const path = str(value);
|
|
32
|
+
// basename handles both separators on win32, so a branch may hand back either.
|
|
33
|
+
return path ? basename(path) : "";
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// Where a tool puts the thing it is working on, most specific first. It is what lets an
|
|
37
|
+
// unknown tool — a provider extension's, a future built-in — still name its subject instead
|
|
38
|
+
// of rendering as a bare noun in the middle column.
|
|
39
|
+
const SUBJECT_KEYS = ["path", "file_path", "file", "filename", "command", "pattern", "query", "url", "name", "text", "message"];
|
|
40
|
+
|
|
41
|
+
const subject = (args: Record<string, unknown>): string => {
|
|
42
|
+
for (const key of SUBJECT_KEYS) {
|
|
43
|
+
const value = args[key];
|
|
44
|
+
const shown = typeof value === "number" && Number.isFinite(value) ? String(value) : str(value);
|
|
45
|
+
// A value that looks like a path is shown as its basename: the directory part is the
|
|
46
|
+
// half of it nobody can read in a 30-column column.
|
|
47
|
+
if (shown) return /[\\/]/.test(shown) ? basename(shown) : shown;
|
|
48
|
+
}
|
|
49
|
+
return "";
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** The reference screen's middle column: present tense, names the thing, no status enums.
|
|
53
|
+
* A flat switch rather than a table because it is what someone reads at 3am, and because
|
|
54
|
+
* every arm needs a different field off `args`. An unknown tool falls through to the generic
|
|
55
|
+
* subject, and a tool that was handed nothing readable falls back to its own name. */
|
|
56
|
+
export const describe = (toolName: string, args: unknown): string => {
|
|
57
|
+
// SAFETY: pi validated `args` against the tool's own schema before firing the event, so
|
|
58
|
+
// every field read below is either the declared type or absent; str()/file() treat
|
|
59
|
+
// absent and wrong-typed identically.
|
|
60
|
+
const a = (args ?? {}) as Record<string, unknown>;
|
|
61
|
+
const say = (sentence: string, subj: string) => (subj ? cut(sentence, LINE_W) : toolName);
|
|
62
|
+
|
|
63
|
+
switch (toolName) {
|
|
64
|
+
case "read":
|
|
65
|
+
return say(`Reading ${file(a.path)}`, file(a.path));
|
|
66
|
+
case "grep": {
|
|
67
|
+
const pattern = cut(str(a.pattern), ARG_W);
|
|
68
|
+
const where = file(a.path) || str(a.glob);
|
|
69
|
+
return say(where ? `Grepping ${pattern} in ${where}` : `Grepping ${pattern}`, pattern);
|
|
70
|
+
}
|
|
71
|
+
case "find":
|
|
72
|
+
return say(`Finding ${cut(str(a.pattern), ARG_W)}`, str(a.pattern));
|
|
73
|
+
case "ls":
|
|
74
|
+
return say(`Listing ${cut(str(a.path) || ".", ARG_W)}`, str(a.path) || ".");
|
|
75
|
+
case "bash": {
|
|
76
|
+
// The first token is the command; the rest is flags nobody reads in a 30-column
|
|
77
|
+
// column.
|
|
78
|
+
const command = str(a.command).split(/\s+/)[0] ?? "";
|
|
79
|
+
return say(`Running ${cut(command, ARG_W)}`, command);
|
|
80
|
+
}
|
|
81
|
+
default: {
|
|
82
|
+
const what = subject(a);
|
|
83
|
+
return what ? cut(`${toolName} ${cut(what, ARG_W)}`, LINE_W) : toolName;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const textOf = (message: AssistantMessage): string =>
|
|
89
|
+
message.content
|
|
90
|
+
.filter((part) => part.type === "text")
|
|
91
|
+
.map((part) => part.text)
|
|
92
|
+
.join("\n");
|
|
93
|
+
|
|
94
|
+
export default function (pi: ExtensionAPI) {
|
|
95
|
+
const path = process.env.PI_BRANCH_STATE;
|
|
96
|
+
// Not a delegate branch. This is also what makes a stray `-e beacon.ts` on any other
|
|
97
|
+
// command line completely inert.
|
|
98
|
+
if (!path) return;
|
|
99
|
+
|
|
100
|
+
let state: BranchState;
|
|
101
|
+
try {
|
|
102
|
+
// Read once. Re-reading later would mean racing the panel's poll for nothing: from
|
|
103
|
+
// here on this process is the file's only writer.
|
|
104
|
+
// SAFETY: the seed is written by run.sh's heredoc or by the fan extension from
|
|
105
|
+
// constrained identifiers, so it parses to BranchState or the branch is misconfigured,
|
|
106
|
+
// which the catch handles by going silent rather than by killing the branch.
|
|
107
|
+
state = JSON.parse(readFileSync(path, "utf8")) as BranchState;
|
|
108
|
+
} catch {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Whole object every time, through tmp + rename, which is atomic on NTFS and POSIX alike.
|
|
113
|
+
// That is what lets the panel poll without ever parsing a half-written row.
|
|
114
|
+
let queued: ReturnType<typeof setTimeout> | undefined;
|
|
115
|
+
const flush = () => {
|
|
116
|
+
queued = undefined;
|
|
117
|
+
try {
|
|
118
|
+
writeFileSync(`${path}.tmp`, JSON.stringify(state));
|
|
119
|
+
renameSync(`${path}.tmp`, path);
|
|
120
|
+
} catch {
|
|
121
|
+
// A branch must never die because the panel's scratch file could not be written.
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** `soon` for anything the next event would overwrite anyway; the default writes now,
|
|
126
|
+
* because a state the branch is about to exit on has no next event to ride along with. */
|
|
127
|
+
const write = (patch: Partial<BranchState>, soon = false) => {
|
|
128
|
+
state = { ...state, ...patch, updatedAt: Date.now() };
|
|
129
|
+
if (!soon) {
|
|
130
|
+
clearTimeout(queued);
|
|
131
|
+
flush();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (queued) return;
|
|
135
|
+
queued = setTimeout(flush, WRITE_MS);
|
|
136
|
+
// A queued write must never be the reason the branch's process stays alive.
|
|
137
|
+
queued.unref?.();
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// One entry per tool call in flight, keyed on the call id pi hands out. A branch runs
|
|
141
|
+
// tools concurrently, and clearing the sentence on the first `end` would leave the row
|
|
142
|
+
// reading "Thinking" while the other call is still mid-grep. `.at(-1)` is the newest,
|
|
143
|
+
// because the sentence people want is the thing that just started.
|
|
144
|
+
const active = new Map<string, string>();
|
|
145
|
+
const sentence = () => [...active.values()].at(-1) ?? "Thinking";
|
|
146
|
+
|
|
147
|
+
pi.on("session_start", (_event, ctx) => {
|
|
148
|
+
// process.pid from inside pi's own process, because `$!` under Git Bash is an MSYS
|
|
149
|
+
// pid that the panel's process.kill would aim at something unrelated.
|
|
150
|
+
write({ pid: process.pid, model: ctx.model?.name ?? state.model, status: "running", activity: "Thinking" });
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
pi.on("tool_execution_start", (event) => {
|
|
154
|
+
active.set(event.toolCallId || event.toolName, describe(event.toolName, event.args));
|
|
155
|
+
write({ activity: sentence() }, true);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Deliberately NOT tool_execution_update: it fires per streamed chunk, which would be a
|
|
159
|
+
// disk write per token.
|
|
160
|
+
pi.on("tool_execution_end", (event) => {
|
|
161
|
+
active.delete(event.toolCallId || event.toolName);
|
|
162
|
+
write({ activity: sentence() }, true);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
pi.on("message_end", (event, ctx) => {
|
|
166
|
+
if (event.message.role !== "assistant") return;
|
|
167
|
+
// SAFETY: role === "assistant" is the discriminant of the AgentMessage union.
|
|
168
|
+
const message = event.message as AssistantMessage;
|
|
169
|
+
// getContextUsage is the figure pi's own status line shows, but it returns null right
|
|
170
|
+
// after a compaction; the message's own total is always there to fall back on.
|
|
171
|
+
const tokens = ctx.getContextUsage()?.tokens ?? message.usage.totalTokens;
|
|
172
|
+
// A provider that reports usage only at completion sends 0 for a while. Keeping the
|
|
173
|
+
// last real value stops the panel's token column from blinking back to nothing.
|
|
174
|
+
write({
|
|
175
|
+
tokens: tokens > 0 ? tokens : state.tokens,
|
|
176
|
+
// A tool call outlives the message that asked for it, so the sentence wins over
|
|
177
|
+
// the generic line whenever one is still running.
|
|
178
|
+
activity: active.size ? sentence() : "Writing the report",
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
pi.on("agent_end", (event) => {
|
|
183
|
+
const last = [...event.messages].reverse().find((message) => message.role === "assistant");
|
|
184
|
+
// SAFETY: same discriminant as above; `last` is undefined only if the loop produced no
|
|
185
|
+
// assistant message at all, which the ?? covers.
|
|
186
|
+
const message = last as AssistantMessage | undefined;
|
|
187
|
+
// The delegate report contract, preserved verbatim: the trailing STATUS line is the
|
|
188
|
+
// whole reason the parent session reads the .md at all.
|
|
189
|
+
const text = message ? textOf(message) : "";
|
|
190
|
+
const match = /^STATUS:\s*(OK|PARTIAL|NEED_STRONGER|ASKING)/m.exec(text);
|
|
191
|
+
// SAFETY: the alternation in the regex is exactly BranchState["report"] minus null.
|
|
192
|
+
const report = (match?.[1] ?? null) as BranchState["report"];
|
|
193
|
+
// The row is where a waiting question gets noticed, so it shows the question itself.
|
|
194
|
+
const question = str(/^QUESTION:\s*(.+)$/m.exec(text)?.[1]);
|
|
195
|
+
const failed = message?.stopReason === "error";
|
|
196
|
+
active.clear();
|
|
197
|
+
write({
|
|
198
|
+
status: failed ? "error" : "done",
|
|
199
|
+
report,
|
|
200
|
+
error: failed ? (message?.errorMessage ?? "the branch stopped with an error") : null,
|
|
201
|
+
activity: failed
|
|
202
|
+
? "Failed"
|
|
203
|
+
: report === "ASKING"
|
|
204
|
+
? cut(`Asking: ${question || "see report"}`, LINE_W)
|
|
205
|
+
: report
|
|
206
|
+
? `Reported ${report}`
|
|
207
|
+
: "Finished",
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Defaults for the branches. Override any of them in ~/.pi/agent/delegate.env, which run.sh
|
|
2
|
+
# reads after this file.
|
|
3
|
+
#
|
|
4
|
+
# FAST is the model a branch runs on unless the task names another. The default is this
|
|
5
|
+
# session's own model, which pi puts in the environment of every bash call.
|
|
6
|
+
FAST=${FAST:-${PI_PROVIDER:+$PI_PROVIDER/${PI_MODEL-}}}
|
|
7
|
+
# Seconds before a branch that is still running is cut off.
|
|
8
|
+
DELEGATE_TIMEOUT=${DELEGATE_TIMEOUT:-300}
|
|
9
|
+
# Extensions every branch loads. Branches start with --no-extensions, so a provider that
|
|
10
|
+
# comes from an extension (claude-bridge, antigravity...) has to be listed here, e.g.
|
|
11
|
+
# LOAD="-e $HOME/.pi/agent/npm/node_modules/pi-claude-bridge"
|
|
12
|
+
LOAD=${LOAD:-}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Your final message is the only thing the caller reads. Write it in exactly this shape and nothing else:
|
|
2
|
+
|
|
3
|
+
STATUS: OK
|
|
4
|
+
SUMMARY: the answer, at most 5 lines
|
|
5
|
+
FILES: one path per line that the answer relies on, or "none"
|
|
6
|
+
|
|
7
|
+
STATUS is OK, PARTIAL (answered part of it), NEED_STRONGER (cannot be answered from reading alone), or ASKING. When in doubt, PARTIAL beats a guess.
|
|
8
|
+
|
|
9
|
+
ASKING is for a decision only the caller can make, not for anything you can find by reading. Stop at once and end with exactly:
|
|
10
|
+
|
|
11
|
+
STATUS: ASKING
|
|
12
|
+
QUESTION: one question, answerable in a line
|
|
13
|
+
OPTIONS: the choices you see, separated by " | ", recommended first
|
|
14
|
+
|
|
15
|
+
Your session is kept; the answer arrives as your next message and you carry on from where you stopped.
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Proves the bash half without ever starting pi. A stub stands in for the binary, records the
|
|
3
|
+
# argv and the environment it was handed, and exits the way its task text asks; then every
|
|
4
|
+
# file the skill claims to write is checked for being real JSON with the right contents.
|
|
5
|
+
#
|
|
6
|
+
# Run it with: bash run.check.sh
|
|
7
|
+
set -u
|
|
8
|
+
|
|
9
|
+
skill=$(cd "$(dirname "$0")" && pwd)
|
|
10
|
+
work=$(mktemp -d)
|
|
11
|
+
cd "$work" || exit 1
|
|
12
|
+
|
|
13
|
+
fail() { echo "FAIL: $*" >&2; exit 1; }
|
|
14
|
+
|
|
15
|
+
cat > fake-pi <<'STUB'
|
|
16
|
+
#!/usr/bin/env bash
|
|
17
|
+
# Ignores every flag; the task is the last argument and decides how this branch ends.
|
|
18
|
+
for arg in "$@"; do task=$arg; done
|
|
19
|
+
printf '%s\n' "$PI_BRANCH_STATE" "$*" >> "$RECORD"
|
|
20
|
+
[ "$task" = boom ] && exit 3
|
|
21
|
+
[ "$task" = slow ] && exit 124
|
|
22
|
+
printf 'STATUS: OK\nSUMMARY: fake\nFILES: none\n'
|
|
23
|
+
STUB
|
|
24
|
+
chmod +x fake-pi
|
|
25
|
+
|
|
26
|
+
desc='A description with "quotes", a \ backslash
|
|
27
|
+
and a newline'
|
|
28
|
+
|
|
29
|
+
PI=$work/fake-pi
|
|
30
|
+
delegate_skill=$skill
|
|
31
|
+
export RECORD=$work/argv.log
|
|
32
|
+
. "$skill/run.sh"
|
|
33
|
+
|
|
34
|
+
dstart check "$desc" map design || fail "dstart"
|
|
35
|
+
branch map core-render "$FAST" ok || fail "first branch"
|
|
36
|
+
branch map broken "$FAST" boom || fail "second branch"
|
|
37
|
+
branch map slow "$FAST" slow || fail "third branch"
|
|
38
|
+
branch design review "$FAST" ok || fail "fourth branch"
|
|
39
|
+
branch map core-render "$FAST" again 2>/dev/null && fail "duplicate branch name was accepted"
|
|
40
|
+
dwait
|
|
41
|
+
|
|
42
|
+
[ -f "$run/run.json" ] || fail "run.json missing"
|
|
43
|
+
node -e 'const fs=require("fs");for(const p of process.argv.slice(1))JSON.parse(fs.readFileSync(p,"utf8"))' \
|
|
44
|
+
"$run/run.json" "$run"/state/*.json || fail "a seed or run.json is not valid JSON"
|
|
45
|
+
|
|
46
|
+
node -e '
|
|
47
|
+
const fs = require("fs");
|
|
48
|
+
const run = JSON.parse(fs.readFileSync(process.argv[1] + "/run.json", "utf8"));
|
|
49
|
+
const want = process.argv[2].replace(/[\n\r\t]/g, "");
|
|
50
|
+
if (run.description !== want) throw new Error("description mangled: " + JSON.stringify(run.description));
|
|
51
|
+
if (run.phases.join(",") !== "map,design") throw new Error("phases: " + run.phases);
|
|
52
|
+
if (!/^\d+$/.test(String(run.startedAt))) throw new Error("startedAt: " + run.startedAt);
|
|
53
|
+
const seed = (stem) => JSON.parse(fs.readFileSync(process.argv[1] + "/state/" + stem + ".json", "utf8"));
|
|
54
|
+
const order = ["map-core-render", "map-broken", "map-slow", "design-review"];
|
|
55
|
+
order.forEach((stem, i) => {
|
|
56
|
+
const s = seed(stem);
|
|
57
|
+
if (s.index !== i + 1) throw new Error(stem + " index " + s.index + ", expected " + (i + 1));
|
|
58
|
+
if (s.status !== "starting" || s.pid !== null || s.tokens !== null || s.parent !== null) throw new Error(stem + " seed fields");
|
|
59
|
+
if (s.activity !== "Starting") throw new Error(stem + " activity");
|
|
60
|
+
});
|
|
61
|
+
' "$run" "$desc" || fail "run.json or a seed has the wrong contents"
|
|
62
|
+
|
|
63
|
+
for pair in map-core-render:0 map-broken:3 map-slow:124 design-review:0; do
|
|
64
|
+
stem=${pair%%:*}; want=${pair##*:}
|
|
65
|
+
got=$(cat "$run/state/$stem.exit" 2>/dev/null)
|
|
66
|
+
[ "$got" = "$want" ] || fail "$stem exited $got, expected $want"
|
|
67
|
+
done
|
|
68
|
+
|
|
69
|
+
# The failure this guards is silent from the panel's side: a branch launched without the
|
|
70
|
+
# beacon writes nothing and sits at "starting" until it exits.
|
|
71
|
+
grep -q -- "-e .*beacon\.ts" "$RECORD" || fail "the beacon was not passed to the branch"
|
|
72
|
+
grep -q -- "--session-dir .*/sessions/map-core-render" "$RECORD" || fail "the branch session is not kept per branch"
|
|
73
|
+
grep -q -- "--tools read,grep,find,ls" "$RECORD" || fail "the read-only tool list was dropped"
|
|
74
|
+
grep -q "map-core-render\.json" "$RECORD" || fail "PI_BRANCH_STATE did not reach the branch"
|
|
75
|
+
[ "$(grep -c "state" "$RECORD")" -ge 4 ] || fail "a branch ran without a state path"
|
|
76
|
+
|
|
77
|
+
# A branch that fans out again: same environment run.sh hands its children, in a subshell so
|
|
78
|
+
# the outer run's variables are untouched. It must join the run above it rather than start a
|
|
79
|
+
# rival one, or the panel draws two runs and nests neither.
|
|
80
|
+
parent_run=$run
|
|
81
|
+
(
|
|
82
|
+
export PI_BRANCH_RUN="$parent_run" PI_BRANCH_PARENT="map-core-render" PI_BRANCH_PHASE="map"
|
|
83
|
+
. "$skill/run.sh"
|
|
84
|
+
dstart nested "a nested run" other || exit 1
|
|
85
|
+
[ "$run" = "$parent_run" ] || exit 2
|
|
86
|
+
branch design child "$FAST" ok || exit 3
|
|
87
|
+
dwait
|
|
88
|
+
) || fail "the nested run did not join its parent (code $?)"
|
|
89
|
+
|
|
90
|
+
[ -f "$run/state/map-child.json" ] || fail "the nested branch did not land in its parent's phase"
|
|
91
|
+
node -e '
|
|
92
|
+
const fs = require("fs");
|
|
93
|
+
const child = JSON.parse(fs.readFileSync(process.argv[1] + "/state/map-child.json", "utf8"));
|
|
94
|
+
if (child.parent !== "map-core-render") throw new Error("parent link: " + child.parent);
|
|
95
|
+
if (child.phase !== "map") throw new Error("phase: " + child.phase);
|
|
96
|
+
const run = JSON.parse(fs.readFileSync(process.argv[1] + "/run.json", "utf8"));
|
|
97
|
+
if (run.name !== "check") throw new Error("a nested run overwrote run.json: " + run.name);
|
|
98
|
+
' "$run" || fail "the nested branch is not nested under its parent"
|
|
99
|
+
|
|
100
|
+
# A finished branch continued with an answer: same state file, same session dir, --continue,
|
|
101
|
+
# the answer as the message, and a fresh exit code. A beacon-written row is what dresume resets.
|
|
102
|
+
node -e '
|
|
103
|
+
const fs = require("fs"); const p = process.argv[1] + "/state/map-broken.json";
|
|
104
|
+
const s = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
105
|
+
fs.writeFileSync(p, JSON.stringify({ ...s, pid: 4242, status: "done", report: "ASKING", activity: "Asking: \"which\" one?" }));
|
|
106
|
+
' "$run"
|
|
107
|
+
dresume "$run" map-broken "use the second" || fail "dresume refused a finished branch"
|
|
108
|
+
wait
|
|
109
|
+
node -e '
|
|
110
|
+
const s = JSON.parse(require("fs").readFileSync(process.argv[1] + "/state/map-broken.json", "utf8"));
|
|
111
|
+
if (s.status !== "starting" || s.pid !== null || s.report !== null || s.activity !== "Resuming") throw new Error(JSON.stringify(s));
|
|
112
|
+
' "$run" || fail "dresume did not reset the row"
|
|
113
|
+
[ "$(cat "$run/state/map-broken.exit")" = 0 ] || fail "dresume did not write a new exit code"
|
|
114
|
+
grep -q -- "--session-dir .*/sessions/map-broken --continue" "$RECORD" || fail "dresume did not continue the branch session"
|
|
115
|
+
grep -q -- "-- use the second$" "$RECORD" || fail "dresume did not pass the answer as the message"
|
|
116
|
+
grep -q "^STATUS: OK" "$run/map-broken.md" || fail "dresume did not write the new report"
|
|
117
|
+
dresume "$run" nope "x" 2>/dev/null && fail "dresume accepted an unknown branch"
|
|
118
|
+
|
|
119
|
+
rm -rf "$work"
|
|
120
|
+
echo "run.check.sh ok"
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# The mechanical half of the delegate skill: seed one state file per branch BEFORE the
|
|
3
|
+
# process exists, then launch that branch with the beacon attached to it.
|
|
4
|
+
#
|
|
5
|
+
# It is a sourced file rather than a block pasted into SKILL.md for two reasons. PI_BRANCH_STATE
|
|
6
|
+
# and `-e beacon.ts` have to travel together — a caller who copies one and forgets the other
|
|
7
|
+
# gets a run where every row sits at "starting" forever — and the check drives exactly the
|
|
8
|
+
# code the skill runs instead of a copy of it that drifts.
|
|
9
|
+
#
|
|
10
|
+
# Usage, from the SESSION cwd, before any `cd`:
|
|
11
|
+
# . <this skill's directory>/run.sh
|
|
12
|
+
# dstart <slug> "<one-line description>" <phase>...
|
|
13
|
+
# branch <phase> <name> <model> "<task>" # repeat, 2 to 6 per run
|
|
14
|
+
# dwait # writes run.json, then waits
|
|
15
|
+
|
|
16
|
+
# The directory this file is in, wherever the skill was installed. A caller may still set it.
|
|
17
|
+
delegate_skill=${delegate_skill:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}
|
|
18
|
+
|
|
19
|
+
# The packaged defaults, then the owner's own settings, which win: $FAST, $LOAD and
|
|
20
|
+
# $DELEGATE_TIMEOUT belong to the machine, not to the skill.
|
|
21
|
+
dconfig() {
|
|
22
|
+
. "$delegate_skill/delegate.env"
|
|
23
|
+
local own="${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}/delegate.env"
|
|
24
|
+
[ -f "$own" ] && . "$own"
|
|
25
|
+
return 0
|
|
26
|
+
}
|
|
27
|
+
# Only the check ever overrides this; it swaps in a stub so the whole block can run without pi.
|
|
28
|
+
PI=${PI:-pi}
|
|
29
|
+
|
|
30
|
+
# MSYS rewrites path-looking *arguments* on the way into a native Windows exe but never
|
|
31
|
+
# rewrites environment variables, so PI_BRANCH_STATE would reach node as "/c/Users/..." and
|
|
32
|
+
# fail every read. cygpath -m gives "C:/Users/..." instead. Absent off Windows, hence the
|
|
33
|
+
# fallback.
|
|
34
|
+
native() {
|
|
35
|
+
if command -v cygpath >/dev/null 2>&1; then cygpath -m "$1"; else printf '%s' "$1"; fi
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# `timeout <seconds> cmd...`. Stock macOS has no timeout (Homebrew coreutils names it
|
|
39
|
+
# gtimeout); with neither, the branch runs without the cap rather than not at all.
|
|
40
|
+
dtimeout() {
|
|
41
|
+
if command -v timeout >/dev/null 2>&1; then timeout "$@"
|
|
42
|
+
elif command -v gtimeout >/dev/null 2>&1; then gtimeout "$@"
|
|
43
|
+
else shift; "$@"; fi
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# The pid the panel checks for liveness. Under Git Bash $$ is an MSYS pid, which the Windows
|
|
47
|
+
# process table knows nothing about; /proc/<pid>/winpid holds the real one there.
|
|
48
|
+
dpid() {
|
|
49
|
+
cat "/proc/$$/winpid" 2>/dev/null || printf '%s' "$$"
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
# The only free text in either file. Both name and description come from the model, so they
|
|
53
|
+
# are escaped rather than trusted; every other field is a constrained identifier or a number.
|
|
54
|
+
json() {
|
|
55
|
+
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' | tr -d '\n\r\t'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
dstart() { # slug description phase...
|
|
59
|
+
d_slug=$1
|
|
60
|
+
d_desc=$2
|
|
61
|
+
shift 2
|
|
62
|
+
d_phases=$(printf ',"%s"' "$@")
|
|
63
|
+
d_phases="[${d_phases:1}]"
|
|
64
|
+
# $PWD before the skill cd's anywhere: the panel watches the session cwd, so a run
|
|
65
|
+
# computed after the cd would land in a directory nobody is looking at.
|
|
66
|
+
d_cwd=$PWD
|
|
67
|
+
# A branch that fans out again JOINS its parent's run instead of starting a rival one:
|
|
68
|
+
# same directory, same state dir, same reader. That is the whole of the panel's `└`
|
|
69
|
+
# nesting, and PI_BRANCH_RUN is exported to every child below, so it is set exactly when
|
|
70
|
+
# this shell is running inside a branch.
|
|
71
|
+
run=${PI_BRANCH_RUN:-"$PWD/.pi-out/$(date +%Y%m%d-%H%M%S)-$d_slug"}
|
|
72
|
+
d_nested=${PI_BRANCH_RUN:+1}
|
|
73
|
+
d_started=$(( $(date +%s) * 1000 ))
|
|
74
|
+
d_index=0
|
|
75
|
+
mkdir -p "$run/state" || return 1
|
|
76
|
+
contract=$(cat "$delegate_skill/report.md")
|
|
77
|
+
dconfig
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
branch() { # phase name model task
|
|
81
|
+
# Inside a nested run the phase is the parent's: the sidebar lists the phases the
|
|
82
|
+
# top-level run declared, and a child's work belongs to the phase its parent is in.
|
|
83
|
+
local phase=${PI_BRANCH_PHASE:-$1}
|
|
84
|
+
local stem="$phase-$2"
|
|
85
|
+
local file="$run/state/$stem.json"
|
|
86
|
+
# Two branches sharing a name would share a state file, and a file with two writers is the
|
|
87
|
+
# one corruption this design cannot detect. A duplicate name is a caller bug, so it is
|
|
88
|
+
# refused rather than auto-renamed.
|
|
89
|
+
[ -e "$file" ] && { echo "delegate: duplicate branch $stem" >&2; return 1; }
|
|
90
|
+
d_index=$(( d_index + 1 ))
|
|
91
|
+
local parent=null
|
|
92
|
+
[ -n "${PI_BRANCH_PARENT:-}" ] && parent="\"$PI_BRANCH_PARENT\""
|
|
93
|
+
local now=$(( $(date +%s) * 1000 ))
|
|
94
|
+
# Every field is populated here, so no column appears for the first time three seconds in
|
|
95
|
+
# and reflows the row.
|
|
96
|
+
cat > "$file" <<JSON
|
|
97
|
+
{"phase":"$phase","name":"$2","parent":$parent,"index":$d_index,"model":"$3","pid":null,
|
|
98
|
+
"status":"starting","activity":"Starting","tokens":null,
|
|
99
|
+
"startedAt":$now,"updatedAt":$now,"report":null,"error":null}
|
|
100
|
+
JSON
|
|
101
|
+
# $LOAD is deliberately unquoted: it carries an `-e <path>` pair that has to word-split.
|
|
102
|
+
# PI_BRANCH_PARENT, PI_BRANCH_RUN and PI_BRANCH_PHASE are set for the CHILD, so a branch
|
|
103
|
+
# given `bash` that sources this file again lands in this run directory, under this row.
|
|
104
|
+
# PI_BRANCH_RUN stays an MSYS path: its only reader is bash inside the branch.
|
|
105
|
+
# stdin is /dev/null because `pi -p` waits on an open stdin pipe until it closes.
|
|
106
|
+
( PI_BRANCH_STATE=$(native "$file") PI_BRANCH_PARENT="$stem" \
|
|
107
|
+
PI_BRANCH_RUN="$run" PI_BRANCH_PHASE="$phase" \
|
|
108
|
+
dtimeout "${DELEGATE_TIMEOUT:-300}" "$PI" -p --session-dir "$(native "$run/sessions/$stem")" --no-skills --no-extensions $LOAD \
|
|
109
|
+
-e "$(native "$delegate_skill/beacon.ts")" \
|
|
110
|
+
--tools "${DELEGATE_TOOLS:-read,grep,find,ls}" --model "$3" --append-system-prompt "$contract" "$4" \
|
|
111
|
+
< /dev/null > "$run/$stem.md" 2> "$run/$stem.err"
|
|
112
|
+
# The one signal that survives a branch dying before its extensions ever bound, and the
|
|
113
|
+
# only place a `timeout` (124) can be told apart from a crash.
|
|
114
|
+
printf '%s' "$?" > "$run/state/$stem.exit" ) &
|
|
115
|
+
|
|
116
|
+
# After the seed, so the directory is never half-built when the panel first reads it.
|
|
117
|
+
write_run_json
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
# The panel treats a missing run.json as "no run", so this file is what makes a run visible at
|
|
121
|
+
# all. It is written after the first seed exists — never before, or a half-built directory
|
|
122
|
+
# would render — and rewritten by every later `branch`, which costs one tiny write and removes
|
|
123
|
+
# the failure this had in real use: a caller who ran `dstart` and `branch` but forgot `dwait`
|
|
124
|
+
# got working reports and a panel that never showed the run, with nothing saying why.
|
|
125
|
+
#
|
|
126
|
+
# A nested run never writes it: the file belongs to the top-level run, and overwriting it would
|
|
127
|
+
# rename the run and drop the phases the sidebar is drawing.
|
|
128
|
+
write_run_json() {
|
|
129
|
+
[ -n "$d_nested" ] && return 0
|
|
130
|
+
cat > "$run/run.json" <<JSON
|
|
131
|
+
{"name":"$(json "$d_slug")","description":"$(json "$d_desc")","cwd":"$(json "$(native "$d_cwd")")","startedAt":$d_started,"phases":$d_phases,"pid":$(dpid)}
|
|
132
|
+
JSON
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
dwait() {
|
|
136
|
+
write_run_json
|
|
137
|
+
wait
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
# A branch that ended on STATUS: ASKING keeps its session under $run/sessions/<stem>, so the
|
|
141
|
+
# answer is simply its next message: `--continue` reopens that session on the same model.
|
|
142
|
+
# Any finished branch can be continued this way, not only an asking one.
|
|
143
|
+
dresume() { # run stem answer
|
|
144
|
+
run=$1
|
|
145
|
+
local stem=$2
|
|
146
|
+
local file="$run/state/$stem.json"
|
|
147
|
+
[ -f "$file" ] || { echo "delegate: no branch $stem in $run" >&2; return 1; }
|
|
148
|
+
[ -e "$run/state/$stem.exit" ] || { echo "delegate: $stem is still running" >&2; return 1; }
|
|
149
|
+
# Joining an existing run: its run.json belongs to whoever started it, so a later dwait in
|
|
150
|
+
# this shell must not rewrite it.
|
|
151
|
+
d_nested=${d_nested-1}
|
|
152
|
+
contract=$(cat "$delegate_skill/report.md")
|
|
153
|
+
dconfig
|
|
154
|
+
local phase
|
|
155
|
+
phase=$(sed -n 's/.*"phase":"\([^"]*\)".*/\1/p' "$file")
|
|
156
|
+
# Back to a live row before the process exists, the same order the seed uses. The beacon
|
|
157
|
+
# writes the pid and the rest at session_start.
|
|
158
|
+
rm "$run/state/$stem.exit"
|
|
159
|
+
# No `sed -i`: BSD sed reads the -E after it as a backup suffix.
|
|
160
|
+
sed -E 's/"status":"[a-z]+"/"status":"starting"/; s/"pid":[0-9]+/"pid":null/;
|
|
161
|
+
s/"report":("[A-Z_]+"|null)/"report":null/; s/"activity":"(\\.|[^"\\])*"/"activity":"Resuming"/' "$file" > "$file.tmp" &&
|
|
162
|
+
mv "$file.tmp" "$file"
|
|
163
|
+
( PI_BRANCH_STATE=$(native "$file") PI_BRANCH_PARENT="$stem" \
|
|
164
|
+
PI_BRANCH_RUN="$run" PI_BRANCH_PHASE="$phase" \
|
|
165
|
+
dtimeout "${DELEGATE_TIMEOUT:-300}" "$PI" -p --session-dir "$(native "$run/sessions/$stem")" --continue \
|
|
166
|
+
--no-skills --no-extensions $LOAD -e "$(native "$delegate_skill/beacon.ts")" \
|
|
167
|
+
--tools "${DELEGATE_TOOLS:-read,grep,find,ls}" --append-system-prompt "$contract" -- "$3" \
|
|
168
|
+
< /dev/null > "$run/$stem.md" 2>> "$run/$stem.err"
|
|
169
|
+
printf '%s' "$?" > "$run/state/$stem.exit" ) &
|
|
170
|
+
}
|