@astrofoundry/pi-astro 0.18.4 → 0.18.6
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/arcane.md +1 -1
- package/extensions/astro-footer/segments.test.ts +2 -1
- package/extensions/astro-footer/segments.ts +2 -1
- package/extensions/astro-subagents/index.ts +22 -9
- package/extensions/astro-subagents/ticker.test.ts +40 -0
- package/extensions/astro-subagents/ticker.ts +36 -0
- package/package.json +1 -1
- package/skills/arcane/SKILL.md +21 -7
package/agents/arcane.md
CHANGED
|
@@ -18,6 +18,6 @@ Rules:
|
|
|
18
18
|
- Project definitions of GitOps-managed projects (Compose files, `.env` layout, project folders) change only through `git show`, `git write`, `git commit`, `git push`, then the project's server-side sync (`arcane-cli gitops`). Never edit a managed project through `projects update` or `projects workspace`, even though the CLI allows it.
|
|
19
19
|
- Operational actions (restart, logs, stats, redeploy, image pull, prune) go straight through the CLI.
|
|
20
20
|
- Confirm destructive operations (`down`, `delete`, `prune`) are explicitly requested in the task before running them.
|
|
21
|
-
- Updating Arcane itself
|
|
21
|
+
- Updating Arcane itself is a hand-over: do the repository change and the sync, then end with the exact hand-over block from the skill (numbered host steps, digests, rollback). Never run `system upgrade` or `projects upgrade arcane`.
|
|
22
22
|
- Report facts from the JSON output. Sensitive fields are already removed; never guess at what was removed.
|
|
23
23
|
- Delegate through `subagent` only to another specialist (`astro.identity`, `astro.network`) and only for that specialist's own area.
|
|
@@ -193,7 +193,8 @@ describe("segments", () => {
|
|
|
193
193
|
expect(out).toContain("2.1k");
|
|
194
194
|
});
|
|
195
195
|
|
|
196
|
-
it("renderExtensionStatus uses muted colour", () => {
|
|
196
|
+
it("renderExtensionStatus uses muted colour, unless the status is already styled", () => {
|
|
197
197
|
expect(renderExtensionStatus(fakeTheme, "🪨 caveman:full")).toBe("<muted>🪨 caveman:full</muted>");
|
|
198
|
+
expect(renderExtensionStatus(fakeTheme, "\u001b[36m⠋ astro.arcane 0:05\u001b[0m")).toBe("\u001b[36m⠋ astro.arcane 0:05\u001b[0m");
|
|
198
199
|
});
|
|
199
200
|
});
|
|
@@ -133,6 +133,7 @@ export function renderCache(
|
|
|
133
133
|
return `${theme.fg("muted", icons.cacheRead)}${theme.fg("text", formatTokens(cacheRead))} ${theme.fg("muted", icons.cacheWrite)}${theme.fg("text", formatTokens(cacheWrite))}`;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/** Statuses that already carry ANSI styling (for example a coloured spinner) render as they are. */
|
|
136
137
|
export function renderExtensionStatus(theme: ThemeFn, value: string): string {
|
|
137
|
-
return theme.fg("muted", value);
|
|
138
|
+
return value.includes("\u001b[") ? value : theme.fg("muted", value);
|
|
138
139
|
}
|
|
@@ -3,10 +3,11 @@ import * as path from "node:path";
|
|
|
3
3
|
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
4
4
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
5
5
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import { Type } from "typebox";
|
|
6
|
+
import { type Static, Type } from "typebox";
|
|
7
7
|
import { type AgentConfig, type AgentDirs, type AgentScope, BUNDLED_NAMESPACE, defaultDirs, discoverAgents, formatAgentList, resolveSkills } from "./agents.ts";
|
|
8
8
|
import { childRemaining, currentDepth, type DispatchDefaults, finalOutput, isFailed, resultOutput, type RunResult, runAgent } from "./child.ts";
|
|
9
9
|
import { renderCall, renderResult, type SubagentDetails } from "./render.ts";
|
|
10
|
+
import { startTicker } from "./ticker.ts";
|
|
10
11
|
|
|
11
12
|
const MAX_PARALLEL_TASKS = 8;
|
|
12
13
|
const MAX_CONCURRENCY = 4;
|
|
@@ -151,6 +152,23 @@ export default function astroSubagents(pi: ExtensionAPI, options: GateOptions =
|
|
|
151
152
|
],
|
|
152
153
|
parameters: Params,
|
|
153
154
|
async execute(_id, params, signal, onUpdate, ctx) {
|
|
155
|
+
const label = params.chain?.length ? `chain of ${params.chain.length}` : params.tasks?.length ? `${params.tasks.length} agents` : (params.agent ?? "subagent");
|
|
156
|
+
const stopTicker = startTicker(ctx, label);
|
|
157
|
+
try {
|
|
158
|
+
return await runTool(params, signal, onUpdate, ctx);
|
|
159
|
+
} finally {
|
|
160
|
+
stopTicker();
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
renderCall: (args, theme) => renderCall(args, theme),
|
|
164
|
+
renderResult: (result, { expanded }, theme) => renderResult(result as AgentToolResult<SubagentDetails>, expanded, theme),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
type ToolParams = Static<typeof Params>;
|
|
169
|
+
type ToolResult = AgentToolResult<SubagentDetails> & { isError?: boolean };
|
|
170
|
+
|
|
171
|
+
async function runTool(params: ToolParams, signal: AbortSignal | undefined, onUpdate: ((r: ToolResult) => void) | undefined, ctx: ExtensionContext): Promise<ToolResult> {
|
|
154
172
|
const scope: AgentScope = params.agentScope ?? "user";
|
|
155
173
|
const discovery = listAgents(ctx.cwd, scope);
|
|
156
174
|
const agents = discovery.agents;
|
|
@@ -227,10 +245,6 @@ export default function astroSubagents(pi: ExtensionAPI, options: GateOptions =
|
|
|
227
245
|
return { content: [{ type: "text", text: `Agent ${result.stopReason ?? "failed"}: ${resultOutput(result)}` }], details: details("single", [result]), isError: true };
|
|
228
246
|
}
|
|
229
247
|
return { content: [{ type: "text", text: finalOutput(result.messages) || "(no output)" }], details: details("single", [result]) };
|
|
230
|
-
},
|
|
231
|
-
renderCall: (args, theme) => renderCall(args, theme),
|
|
232
|
-
renderResult: (result, { expanded }, theme) => renderResult(result as AgentToolResult<SubagentDetails>, expanded, theme),
|
|
233
|
-
});
|
|
234
248
|
}
|
|
235
249
|
|
|
236
250
|
pi.registerCommand("run", {
|
|
@@ -246,16 +260,15 @@ export default function astroSubagents(pi: ExtensionAPI, options: GateOptions =
|
|
|
246
260
|
return;
|
|
247
261
|
}
|
|
248
262
|
const agents = listAgents(ctx.cwd, "user").agents;
|
|
249
|
-
ctx
|
|
250
|
-
ctx.ui.setStatus("subagent", `${parsed.agent} running`);
|
|
263
|
+
const stopTicker = startTicker(ctx, parsed.agent);
|
|
251
264
|
let result: RunResult;
|
|
252
265
|
try {
|
|
253
266
|
result = await runOne(ctx, agents, parsed.agent, parsed.task, undefined, undefined, undefined, undefined);
|
|
254
267
|
} finally {
|
|
255
|
-
|
|
268
|
+
stopTicker();
|
|
256
269
|
}
|
|
257
270
|
const status = isFailed(result) ? "failed" : "done";
|
|
258
|
-
ctx.ui.notify(`${parsed.agent}
|
|
271
|
+
if (isFailed(result)) ctx.ui.notify(`${parsed.agent} failed`, "warning");
|
|
259
272
|
pi.sendMessage(
|
|
260
273
|
{ customType: "astro-subagents", content: `Result from /run ${parsed.agent} (${status}). Task: ${parsed.task}\n\n${resultOutput(result)}`, display: true },
|
|
261
274
|
{ deliverAs: "followUp", triggerTurn: true },
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { formatElapsed, startTicker, tickerText } from "./ticker.ts";
|
|
3
|
+
|
|
4
|
+
describe("ticker", () => {
|
|
5
|
+
it("formats elapsed time as m:ss", () => {
|
|
6
|
+
expect(formatElapsed(0)).toBe("0:00");
|
|
7
|
+
expect(formatElapsed(59_999)).toBe("0:59");
|
|
8
|
+
expect(formatElapsed(61_000)).toBe("1:01");
|
|
9
|
+
expect(formatElapsed(3_600_000)).toBe("60:00");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("cycles spinner frames", () => {
|
|
13
|
+
expect(tickerText("astro.arcane", 0, 1000)).toBe("⠋ astro.arcane 0:01");
|
|
14
|
+
expect(tickerText("astro.arcane", 10, 1000)).toBe("⠋ astro.arcane 0:01");
|
|
15
|
+
expect(tickerText("astro.arcane", 1, 65_000)).toBe("⠙ astro.arcane 1:05");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("paints accent-coloured status until stopped and then clears it", () => {
|
|
19
|
+
vi.useFakeTimers();
|
|
20
|
+
const setStatus = vi.fn();
|
|
21
|
+
const fg = vi.fn((color: string, text: string) => `<${color}>${text}</${color}>`);
|
|
22
|
+
const ctx = { hasUI: true, ui: { setStatus, theme: { fg } } } as unknown as Parameters<typeof startTicker>[0];
|
|
23
|
+
let clock = 0;
|
|
24
|
+
const stop = startTicker(ctx, "astro.network", 100, () => clock);
|
|
25
|
+
expect(setStatus).toHaveBeenLastCalledWith("subagent", "<accent>⠋ astro.network 0:00</accent>");
|
|
26
|
+
clock = 1500;
|
|
27
|
+
vi.advanceTimersByTime(100);
|
|
28
|
+
expect(setStatus).toHaveBeenLastCalledWith("subagent", "<accent>⠙ astro.network 0:01</accent>");
|
|
29
|
+
stop();
|
|
30
|
+
expect(setStatus).toHaveBeenLastCalledWith("subagent", undefined);
|
|
31
|
+
vi.useRealTimers();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("does nothing without a UI", () => {
|
|
35
|
+
const setStatus = vi.fn();
|
|
36
|
+
const stop = startTicker({ hasUI: false, ui: { setStatus } } as unknown as Parameters<typeof startTicker>[0], "x");
|
|
37
|
+
stop();
|
|
38
|
+
expect(setStatus).not.toHaveBeenCalled();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
4
|
+
const STATUS_KEY = "subagent";
|
|
5
|
+
|
|
6
|
+
export function formatElapsed(ms: number): string {
|
|
7
|
+
const total = Math.max(0, Math.floor(ms / 1000));
|
|
8
|
+
const minutes = Math.floor(total / 60);
|
|
9
|
+
const seconds = total % 60;
|
|
10
|
+
return `${minutes}:${String(seconds).padStart(2, "0")}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function tickerText(label: string, frameIndex: number, elapsedMs: number): string {
|
|
14
|
+
return `${FRAMES[frameIndex % FRAMES.length]} ${label} ${formatElapsed(elapsedMs)}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Animated footer status in the accent colour while a subagent runs.
|
|
19
|
+
* Returns a stop function that clears the status. A no-op without UI.
|
|
20
|
+
*/
|
|
21
|
+
export function startTicker(ctx: ExtensionContext, label: string, intervalMs = 250, now: () => number = Date.now): () => void {
|
|
22
|
+
if (!ctx.hasUI) return () => {};
|
|
23
|
+
const started = now();
|
|
24
|
+
let frame = 0;
|
|
25
|
+
const paint = () => {
|
|
26
|
+
const text = tickerText(label, frame++, now() - started);
|
|
27
|
+
ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme ? ctx.ui.theme.fg("accent", text) : text);
|
|
28
|
+
};
|
|
29
|
+
paint();
|
|
30
|
+
const timer = setInterval(paint, intervalMs);
|
|
31
|
+
timer.unref();
|
|
32
|
+
return () => {
|
|
33
|
+
clearInterval(timer);
|
|
34
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
35
|
+
};
|
|
36
|
+
}
|
package/package.json
CHANGED
package/skills/arcane/SKILL.md
CHANGED
|
@@ -29,13 +29,27 @@ The full `arcane-cli` is available. The tool refuses only `config`, `auth`, `sel
|
|
|
29
29
|
|
|
30
30
|
## Updating Arcane itself
|
|
31
31
|
|
|
32
|
-
The `arcane` project is GitOps-managed and pinned by digest (`ghcr.io/getarcaneapp/manager@sha256:...`). The
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
32
|
+
The `arcane` project is GitOps-managed and pinned by digest (`ghcr.io/getarcaneapp/manager@sha256:...`). The manager cannot recreate its own container, so the update is a hand-over: you do the repository and sync work, then give the caller exact steps for VM 100 and verify afterwards. Never use `["system", "upgrade"]` or `["projects", "upgrade", "arcane"]` for the manager; both bypass the pinned digest in the repository.
|
|
33
|
+
|
|
34
|
+
Procedure, in this order:
|
|
35
|
+
|
|
36
|
+
1. Facts: `["version"]` (running version and digest), `["projects", "updates"]` (new digest, if any). If there is no update, report that and stop.
|
|
37
|
+
2. Repository: `git pull`, `git show arcane/compose.yaml`, `git write arcane/compose.yaml <file with the new digest, nothing else changed>`, `git diff`, `git commit "Update Arcane manager to <version>"`, `git push`.
|
|
38
|
+
3. Sync: `["gitops", "sync", "arcane", "--yes"]`, then `["gitops", "status", "arcane"]` until the last synced commit is yours. This updates `/opt/docker/arcane/compose.yaml` on VM 100; the container keeps running the old image.
|
|
39
|
+
4. Hand over. End your answer with exactly this block, values filled in:
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
Arcane manager update <old version> -> <new version>
|
|
43
|
+
Repository: commit <short hash> pushed and synced (digest <new digest, first 12 chars>).
|
|
44
|
+
Your steps on VM 100 (ssh arcane), expect ~1 minute of Arcane UI/API downtime; other containers keep running:
|
|
45
|
+
1. cd /opt/docker/arcane && docker compose pull arcane && docker compose up -d arcane
|
|
46
|
+
2. docker inspect arcane --format "{{.Config.Image}} {{.State.Status}}" (expect the new digest, running)
|
|
47
|
+
Then ask me to verify; I will check version, projects, and GitOps status.
|
|
48
|
+
Rollback if the manager does not come back: git revert <short hash> in the repository (ask me), then on VM 100: docker compose up -d arcane with the previous digest <old digest, first 12 chars>.
|
|
49
|
+
Last Restic backup of arcane-data: nightly at 03:00; check the date before you start if the change worries you.
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
5. When the caller reports back: `["version"]` must show the new digest, `["projects", "list"]` must show `arcane` running, `["gitops", "status", "arcane"]` must be clean. Report the three facts.
|
|
39
53
|
|
|
40
54
|
## Output
|
|
41
55
|
|