@staix/agent-hub 0.29.0 → 0.30.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/CHANGELOG.md +12 -0
- package/README.md +5 -3
- package/docs/agent-notes/README.md +12 -0
- package/docs/agent-notes/adapters.md +2 -1
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/daemon.md +2 -1
- package/docs/agent-notes/tasks.md +4 -2
- package/docs/agent-notes/tests.md +2 -0
- package/docs/design/adapters.md +56 -0
- package/docs/design/agent-hub-architecture.md +12 -0
- package/docs/design/bus.md +8 -0
- package/docs/design/task-board.md +18 -0
- package/docs/dogfood.md +7 -4
- package/docs/operations.md +106 -11
- package/docs/quickstart.md +10 -3
- package/docs/sdlc.md +1 -1
- package/docs/security.md +28 -2
- package/docs/smoke.md +13 -2
- package/docs/specs/2026-10-12-office-view-design.md +86 -3
- package/docs/verification/2026-11-03-0.30.0.md +54 -0
- package/docs/verification/2026-11-03-agy-1.4.12-launcher-nonce.md +42 -0
- package/docs/verification/2026-11-03-agy-1.4.12.md +90 -0
- package/docs/verification/2026-11-03-agy-tui-close.md +49 -0
- package/docs/verification/2026-11-03-grok-1.0.62-t0.md +150 -0
- package/docs/verification/2026-11-03-pi-150-compat.md +40 -0
- package/docs/verification/2026-11-03-pi-title-api.md +26 -0
- package/docs/verification/2026-11-03-qwen-0265-acp.md +55 -0
- package/docs/verification/2026-11-03-qwen-0265-dual-output.md +40 -0
- package/docs/verification/dogfood/2026-11-03.md +3095 -0
- package/docs/verification/pi-150-model-free.ts +57 -0
- package/docs/verification/pi-150-native.mjs +21 -0
- package/docs/verification/pi-150-title-api.mjs +31 -0
- package/docs/verified.json +14 -14
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +193 -29
- package/src/adapters/compat.ts +10 -10
- package/src/adapters/definitions/builtin-metadata.ts +2 -2
- package/src/adapters/local-worker.ts +22 -6
- package/src/adapters/pi.ts +92 -10
- package/src/adapters/registry-types.ts +4 -0
- package/src/adapters/registry.ts +2 -0
- package/src/cli/autonomy.ts +66 -1
- package/src/cli/console-state.ts +6 -2
- package/src/cli/help.ts +2 -0
- package/src/cli/identity.ts +8 -1
- package/src/cli/main.ts +39 -0
- package/src/cli/worktree-tidy.ts +158 -0
- package/src/cli/worktrees-output.ts +43 -0
- package/src/hub/board.ts +28 -3
- package/src/hub/bus.ts +4 -4
- package/src/hub/conductor.ts +4 -3
- package/src/hub/config-apply.ts +2 -1
- package/src/hub/daemon.ts +237 -19
- package/src/hub/dogfood-read.ts +62 -9
- package/src/hub/hub-tools.ts +11 -5
- package/src/hub/manager.ts +9 -1
- package/src/hub/peer-doing.ts +33 -3
- package/src/hub/peers.ts +7 -0
- package/src/hub/settings.ts +3 -0
- package/src/hub/tasks.ts +30 -8
- package/src/hub/ui.ts +2 -0
- package/src/hub/worktree-prs.ts +100 -0
- package/src/hub/worktree-shelf.ts +246 -0
- package/src/memory/capture.ts +1 -1
- package/src/pi/extension.ts +43 -3
- package/src/ui/index.html +133 -10
- package/src/ui/office-model.ts +64 -8
- package/src/ui/office-space.ts +1 -0
- package/src/ui/plugin.ts +14 -3
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { stat } from "node:fs/promises";
|
|
3
|
+
import { basename, join, relative, sep } from "node:path";
|
|
4
|
+
import { childEnv } from "./child-process.ts";
|
|
5
|
+
import { hubGitArgv, hubGitEnv } from "./git.ts";
|
|
6
|
+
import { HUB } from "./envelope.ts";
|
|
7
|
+
import { realPath } from "./project.ts";
|
|
8
|
+
import type { Task } from "./board.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* #1057 worktree shelf. Which worktrees are finished is DERIVED at read time from facts and two person overrides, never stored as a flag.
|
|
12
|
+
* This module reads facts and computes the partition. It never deletes a worktree or a local branch: removal exists only in the person's
|
|
13
|
+
* CLI process (`worktree-tidy.ts`, a later change), and `test/worktree-shelf.test.ts` greps the daemon-reachable modules for any removal argv.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type PrState = "open" | "merged" | "closed" | "unknown";
|
|
17
|
+
/** `settled` is what a PII-bound row says in a reply for a merged or closed PR: the reason is real on the host, but a reply carries no PR state of a private task. */
|
|
18
|
+
export type SettledReason = "merged" | "closed" | "quiet" | "parked" | "settled";
|
|
19
|
+
export type OverrideValue = "settled" | "active";
|
|
20
|
+
export interface WorktreeOverride { path: string; value: OverrideValue; at: number }
|
|
21
|
+
|
|
22
|
+
export interface WorktreeFact {
|
|
23
|
+
/** Absolute, host-only. A row shown to a person is displayed through `displayPath`. */
|
|
24
|
+
path: string;
|
|
25
|
+
branch: string | null;
|
|
26
|
+
head: string;
|
|
27
|
+
locked: boolean;
|
|
28
|
+
prunable: boolean;
|
|
29
|
+
/** The project's main checkout: never shelved. */
|
|
30
|
+
main: boolean;
|
|
31
|
+
/** A configured instance worktree (`.worktrees/<instance>`): never shelved. */
|
|
32
|
+
instance: boolean;
|
|
33
|
+
/** Max of the HEAD commit time, the worktree index mtime and the latest time someone other than the hub wrote to the bound task. 0 when none could be read. */
|
|
34
|
+
lastActiveAt: number;
|
|
35
|
+
/** False when the commit time or the index could not be read (a failed git call or a spent budget): such a row is never settled by a PR or by quiet time, because 'no newer work' is unknown. */
|
|
36
|
+
activityKnown?: boolean;
|
|
37
|
+
/** `private`: the bound task is a PII task. The binding still drives the partition and tidy's refusals on the host, but `row()` never puts its id, PR or state in a reply. */
|
|
38
|
+
task?: { id: number; state: string; pr?: number; repo?: string; private?: true };
|
|
39
|
+
pr: { state: PrState; changedAt?: number };
|
|
40
|
+
}
|
|
41
|
+
/** A row built for a reply. A PII-bound row carries `private: true` and `taskState: "private"` and no task id or PR. This is the one place a reply row is made, so redaction happens here (AGENTS.md), never at a call site. */
|
|
42
|
+
export interface ShelfRow { path: string; branch: string | null; reason?: SettledReason; task?: number; taskState?: string; pr?: number; private?: true; /** The main checkout or a declared instance worktree: never shelved, so a panel offers no Park for it. */ fixed?: true; lastActiveAt: number }
|
|
43
|
+
export interface Partition { live: ShelfRow[]; settled: ShelfRow[] }
|
|
44
|
+
|
|
45
|
+
const DAY_MS = 86_400_000;
|
|
46
|
+
const row = (fact: WorktreeFact, reason?: SettledReason): ShelfRow => ({
|
|
47
|
+
path: fact.path, branch: fact.branch, lastActiveAt: fact.lastActiveAt, ...(fact.main || fact.instance ? { fixed: true as const } : {}),
|
|
48
|
+
...(reason ? { reason: fact.task?.private && (reason === "merged" || reason === "closed") ? "settled" as const : reason } : {}),
|
|
49
|
+
...(fact.task?.private ? { private: true as const, taskState: "private" }
|
|
50
|
+
: { ...(fact.task ? { task: fact.task.id, taskState: fact.task.state } : {}), ...(fact.task?.pr !== undefined ? { pr: fact.task.pr } : {}) }),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/** An override counts only while no newer activity exists: `lastActiveAt <= at` (D2). Newer activity makes it inert. */
|
|
54
|
+
export const overrideInForce = (override: WorktreeOverride | undefined, lastActiveAt: number): override is WorktreeOverride =>
|
|
55
|
+
!!override && lastActiveAt <= override.at;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* D3, pure. A row whose activity is unknown (`activityKnown === false`) is live: every settling rule compares with `lastActiveAt`, which is then not a fact.
|
|
59
|
+
* Rules in order: (1) the main checkout and instance worktrees are never shelved; (2) an effective override wins; (3) a merged or
|
|
60
|
+
* closed PR with `lastActiveAt <= changedAt` is settled; (4) `quietDays > 0` and a quiet span of at least that long is settled; (5) live.
|
|
61
|
+
* `now`, the facts and the overrides are arguments: no clock, no I/O.
|
|
62
|
+
*/
|
|
63
|
+
export function partition(facts: readonly WorktreeFact[], overrides: readonly WorktreeOverride[], now: number, quietDays: number): Partition {
|
|
64
|
+
const byPath = new Map(overrides.map(o => [o.path, o]));
|
|
65
|
+
const out: Partition = { live: [], settled: [] };
|
|
66
|
+
for (const fact of facts) {
|
|
67
|
+
if (fact.main || fact.instance || fact.activityKnown === false) { out.live.push(row(fact)); continue; }
|
|
68
|
+
const override = byPath.get(fact.path);
|
|
69
|
+
if (overrideInForce(override, fact.lastActiveAt)) {
|
|
70
|
+
if (override.value === "settled") out.settled.push(row(fact, "parked")); else out.live.push(row(fact));
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if ((fact.pr.state === "merged" || fact.pr.state === "closed") && fact.pr.changedAt !== undefined && fact.lastActiveAt <= fact.pr.changedAt) {
|
|
74
|
+
out.settled.push(row(fact, fact.pr.state)); continue;
|
|
75
|
+
}
|
|
76
|
+
if (quietDays > 0 && fact.lastActiveAt > 0 && now - fact.lastActiveAt >= quietDays * DAY_MS) { out.settled.push(row(fact, "quiet")); continue; }
|
|
77
|
+
out.live.push(row(fact));
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Project-relative inside the root (the root itself is `.`), otherwise the basename only: paths stay host-only, as in `snapshots.ts`. */
|
|
83
|
+
export function displayPath(root: string, path: string): string {
|
|
84
|
+
if (path === root) return ".";
|
|
85
|
+
const rel = relative(root, path);
|
|
86
|
+
return rel && !rel.startsWith("..") && !rel.startsWith(sep) && rel !== path ? rel : basename(path);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Whether a path is the root or below it, so a row can be resolved against the project root and one outside it cannot. */
|
|
90
|
+
export const shelfInsideRoot = (root: string, path: string): boolean => path === root || (!relative(root, path).startsWith("..") && !relative(root, path).startsWith(sep) && relative(root, path) !== path);
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The latest time anyone but the hub wrote to a task: its work, not its bookkeeping. The hub's own entries (the merge result, the owner notice,
|
|
94
|
+
* sweeps) follow the event they record, so counting them would put `lastActiveAt` after a merge's `changedAt` and a merged task could never settle (#1057 D1/D3).
|
|
95
|
+
*/
|
|
96
|
+
export const taskWorkAt = (task: Pick<Task, "history" | "created">): number =>
|
|
97
|
+
Math.max(task.created, ...task.history.filter(h => h.by !== HUB && Number.isFinite(h.at)).map(h => h.at));
|
|
98
|
+
|
|
99
|
+
/** The task a worktree branch belongs to: the open task, else the most recently updated one, whose `refs.branch` equals the branch. */
|
|
100
|
+
export function boundTask(tasks: readonly Pick<Task, "id" | "state" | "refs" | "sdlc" | "updated" | "created" | "history">[], branch: string | null): WorktreeFact["task"] & { workAt: number } | undefined {
|
|
101
|
+
if (!branch) return undefined;
|
|
102
|
+
const matching = tasks.filter(t => t.refs.branch === branch);
|
|
103
|
+
if (!matching.length) return undefined;
|
|
104
|
+
const closed = (state: string) => state === "approved" || state === "cancelled";
|
|
105
|
+
const pick = [...matching].sort((a, b) => Number(closed(a.state)) - Number(closed(b.state)) || b.updated - a.updated)[0]!;
|
|
106
|
+
const pr = pick.sdlc?.pr, repo = pick.sdlc?.repo;
|
|
107
|
+
return { id: pick.id, state: pick.state, ...(typeof pr === "number" ? { pr } : {}), ...(typeof repo === "string" && typeof pr === "number" ? { repo } : {}), workAt: taskWorkAt(pick) };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* PR state from evidence already on the board (D1): only the guarded merge's own record says `merged`. A cancelled task says nothing about its PR (`Tasks.cancel`
|
|
112
|
+
* touches only the board, so the PR can still be open on GitHub), and a task with no record says nothing either: both are `unknown`. Only the explicit refresh
|
|
113
|
+
* action, which reads GitHub, may learn `closed` or `open` (a later change).
|
|
114
|
+
*/
|
|
115
|
+
export function prFromTask(task: Pick<Task, "state" | "sdlc" | "history"> | undefined, observed?: PrObserver): WorktreeFact["pr"] {
|
|
116
|
+
const merge = task?.sdlc?.merge;
|
|
117
|
+
if (merge && merge.state === "merged" && Number.isFinite(merge.at)) return { state: "merged", changedAt: merge.at };
|
|
118
|
+
// A PR state the person asked the hub to read (the explicit refresh action) is a stored fact, never fetched here: this module has no GitHub access (AC5).
|
|
119
|
+
const repo = task?.sdlc?.repo, pr = task?.sdlc?.pr;
|
|
120
|
+
const seen = typeof repo === "string" && typeof pr === "number" ? observed?.(repo, pr) : undefined;
|
|
121
|
+
if (seen) return { state: seen.state, ...(seen.changedAt !== undefined && seen.state !== "open" ? { changedAt: seen.changedAt } : {}) };
|
|
122
|
+
return { state: "unknown" };
|
|
123
|
+
}
|
|
124
|
+
/** The stored result of an explicit PR refresh for one repository and PR number, or undefined when none was read. */
|
|
125
|
+
export type PrObserver = (repo: string, pr: number) => { state: "open" | "merged" | "closed"; changedAt?: number } | undefined;
|
|
126
|
+
|
|
127
|
+
export interface RawWorktree { path: string; branch: string | null; head: string; locked: boolean; prunable: boolean }
|
|
128
|
+
/** `git worktree list --porcelain -z`: one NUL-separated field per attribute, an empty field ends a record. Bounded. */
|
|
129
|
+
export function parseWorktreeList(stdout: string, limit = 256): { rows: RawWorktree[]; complete: boolean } {
|
|
130
|
+
const rows: RawWorktree[] = [];
|
|
131
|
+
let current: Partial<RawWorktree> | undefined;
|
|
132
|
+
const flush = () => { if (current?.path) rows.push({ path: current.path, branch: current.branch ?? null, head: current.head ?? "", locked: !!current.locked, prunable: !!current.prunable }); current = undefined; };
|
|
133
|
+
for (const field of stdout.split("\0")) {
|
|
134
|
+
if (field === "") { flush(); continue; }
|
|
135
|
+
if (field.startsWith("worktree ")) { flush(); current = { path: field.slice(9) }; continue; }
|
|
136
|
+
if (!current) continue;
|
|
137
|
+
if (field.startsWith("HEAD ")) current.head = field.slice(5);
|
|
138
|
+
else if (field.startsWith("branch refs/heads/")) current.branch = field.slice("branch refs/heads/".length);
|
|
139
|
+
else if (field === "locked" || field.startsWith("locked ")) current.locked = true;
|
|
140
|
+
else if (field === "prunable" || field.startsWith("prunable ")) current.prunable = true;
|
|
141
|
+
}
|
|
142
|
+
flush();
|
|
143
|
+
return { rows: rows.slice(0, limit), complete: rows.length <= limit };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface ShelfGitPort {
|
|
147
|
+
/** Read-only git, bounded by `timeoutMs`, never blocking the event loop. `undefined` is a failed or timed-out read, never an empty answer. */
|
|
148
|
+
run(cwd: string, args: string[], timeoutMs: number): Promise<{ status: number | null; stdout: string } | undefined>;
|
|
149
|
+
}
|
|
150
|
+
/** One git call's ceiling, and the whole read's: a slow repository answers `complete: false` after this, never a stalled hub (D1's snapshot-budget pattern). */
|
|
151
|
+
export const READ_TIMEOUT_MS = 5_000, TOTAL_BUDGET_MS = 3_000, READ_CONCURRENCY = 4;
|
|
152
|
+
export const realShelfGit: ShelfGitPort = {
|
|
153
|
+
async run(cwd, args, timeoutMs) {
|
|
154
|
+
if (!shelfReadAllowed(args)) throw new Error("the worktree shelf runs read-only git commands only");
|
|
155
|
+
const argv = hubGitArgv(["-C", cwd, "-c", "gc.auto=0", "-c", "maintenance.auto=false", ...args]);
|
|
156
|
+
return await new Promise(resolve => {
|
|
157
|
+
execFile(argv[0]!, argv.slice(1), { encoding: "utf8", timeout: Math.max(1, Math.min(READ_TIMEOUT_MS, timeoutMs)), killSignal: "SIGKILL", maxBuffer: 1024 * 1024, env: hubGitEnv(childEnv(), { repositoryIsolation: true }) }, (error, stdout) => {
|
|
158
|
+
const code = (error as (Error & { code?: unknown }) | null)?.code;
|
|
159
|
+
// a non-zero exit is an answer (status), a spawn failure, timeout or overflow is not
|
|
160
|
+
resolve(error && typeof code !== "number" ? undefined : { status: typeof code === "number" ? code : 0, stdout: String(stdout ?? "") });
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
/**
|
|
166
|
+
* The only git argv shapes the shelf runs, all reads. `realShelfGit.run` refuses anything else before spawning, so no removal, branch, ref or
|
|
167
|
+
* prune argv can reach git from the daemon (AC4); the boundary test also greps the module text for them.
|
|
168
|
+
*/
|
|
169
|
+
export const SHELF_GIT_READS: readonly (readonly string[])[] = [
|
|
170
|
+
["worktree", "list", "--porcelain", "-z"],
|
|
171
|
+
["log", "-1", "--format=%ct", "HEAD"],
|
|
172
|
+
["rev-parse", "--absolute-git-dir"],
|
|
173
|
+
];
|
|
174
|
+
export const shelfReadAllowed = (args: readonly string[]): boolean => SHELF_GIT_READS.some(shape => shape.length === args.length && shape.every((word, i) => word === args[i]));
|
|
175
|
+
|
|
176
|
+
export interface ShelfInput {
|
|
177
|
+
root: string;
|
|
178
|
+
/** Called AFTER the git reads, so the board is read once and fresh, never held across an await. */
|
|
179
|
+
tasks: () => readonly Pick<Task, "id" | "state" | "refs" | "sdlc" | "updated" | "created" | "history">[];
|
|
180
|
+
/** Whether a task is a PII task (`Tasks.isPii`). Its binding is kept for the partition and the refusals and redacted in the reply row. A host that does not pass it treats no task as private. */
|
|
181
|
+
isPrivate?: (taskId: number) => boolean;
|
|
182
|
+
/** Resolved instance worktree paths (`.worktrees/<instance>`): never shelved. */
|
|
183
|
+
instancePaths: readonly string[];
|
|
184
|
+
/** Stored PR observations (D1); called after the git reads, in the same synchronous segment as `tasks`. */
|
|
185
|
+
observed?: PrObserver;
|
|
186
|
+
git?: ShelfGitPort;
|
|
187
|
+
now?: () => number;
|
|
188
|
+
budgetMs?: number;
|
|
189
|
+
}
|
|
190
|
+
export interface ShelfFacts { facts: WorktreeFact[]; complete: boolean; available: boolean }
|
|
191
|
+
|
|
192
|
+
/** The worktree's own index mtime (D1: it covers uncommitted editing). `undefined` is unknown; a missing index file is a known 0. */
|
|
193
|
+
async function indexMtime(git: ShelfGitPort, path: string, timeoutMs: number): Promise<number | undefined> {
|
|
194
|
+
const dir = await git.run(path, ["rev-parse", "--absolute-git-dir"], timeoutMs);
|
|
195
|
+
if (!dir || dir.status !== 0) return undefined;
|
|
196
|
+
try { const info = await stat(join(dir.stdout.trim(), "index")); return info.isFile() ? info.mtimeMs : 0; }
|
|
197
|
+
catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT" ? 0 : undefined; }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
interface Probe { commitAt: number; touched: number; known: boolean }
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* D1: facts per worktree. One `worktree list`, then a commit-time read and an index read per worktree, at most `READ_CONCURRENCY` at a time and all under one
|
|
204
|
+
* total budget. Asynchronous throughout: the daemon's event loop is never held. When the budget is spent, the rows not yet read are still listed, with unknown
|
|
205
|
+
* activity (live, never settled), and the result says `complete: false`. Never writes.
|
|
206
|
+
*/
|
|
207
|
+
export async function readWorktreeFacts(input: ShelfInput): Promise<ShelfFacts> {
|
|
208
|
+
const git = input.git ?? realShelfGit, now = input.now ?? Date.now, deadline = now() + (input.budgetMs ?? TOTAL_BUDGET_MS);
|
|
209
|
+
const left = () => Math.max(0, deadline - now());
|
|
210
|
+
let root: string;
|
|
211
|
+
try { root = realPath(input.root); } catch { return { facts: [], complete: false, available: false }; }
|
|
212
|
+
const listed = await git.run(root, ["worktree", "list", "--porcelain", "-z"], left());
|
|
213
|
+
if (!listed || listed.status !== 0) return { facts: [], complete: false, available: false };
|
|
214
|
+
const parsed = parseWorktreeList(listed.stdout);
|
|
215
|
+
const paths = parsed.rows.map(raw => { try { return realPath(raw.path); } catch { return raw.path; } }); // a prunable worktree's directory is gone: keep the registered path
|
|
216
|
+
const probes: (Probe | undefined)[] = parsed.rows.map(() => undefined);
|
|
217
|
+
let next = 0, cut = false;
|
|
218
|
+
const worker = async () => {
|
|
219
|
+
for (;;) {
|
|
220
|
+
const i = next++;
|
|
221
|
+
if (i >= paths.length) return;
|
|
222
|
+
if (left() <= 0) { cut = true; return; }
|
|
223
|
+
const stamp = await git.run(paths[i]!, ["log", "-1", "--format=%ct", "HEAD"], left());
|
|
224
|
+
const text = stamp && stamp.status === 0 ? stamp.stdout.trim() : "";
|
|
225
|
+
const touched = left() > 0 ? await indexMtime(git, paths[i]!, left()) : undefined;
|
|
226
|
+
if (!/^\d{1,12}$/.test(text) || touched === undefined) { probes[i] = { commitAt: 0, touched: 0, known: false }; cut = cut || left() <= 0; continue; }
|
|
227
|
+
probes[i] = { commitAt: Number(text) * 1000, touched, known: true };
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
await Promise.all(Array.from({ length: Math.min(READ_CONCURRENCY, paths.length) }, worker));
|
|
231
|
+
// From here to the return nothing is awaited: the board is read once, synchronously, so it is current for every row.
|
|
232
|
+
const tasks = input.tasks(), instances = new Set(input.instancePaths), facts: WorktreeFact[] = [];
|
|
233
|
+
let unread = false;
|
|
234
|
+
parsed.rows.forEach((raw, i) => {
|
|
235
|
+
const path = paths[i]!, probe = probes[i], task = boundTask(tasks, raw.branch), bound = task ? tasks.find(t => t.id === task.id) : undefined;
|
|
236
|
+
if (!probe?.known) unread = true;
|
|
237
|
+
facts.push({
|
|
238
|
+
path, branch: raw.branch, head: raw.head, locked: raw.locked, prunable: raw.prunable,
|
|
239
|
+
main: path === root, instance: instances.has(path),
|
|
240
|
+
lastActiveAt: Math.max(probe?.commitAt ?? 0, probe?.touched ?? 0, task?.workAt ?? 0), activityKnown: probe?.known === true,
|
|
241
|
+
...(task ? { task: { id: task.id, state: task.state, ...(task.pr !== undefined ? { pr: task.pr } : {}), ...(task.repo !== undefined ? { repo: task.repo } : {}), ...(input.isPrivate?.(task.id) ? { private: true as const } : {}) } } : {}),
|
|
242
|
+
pr: prFromTask(bound, input.observed),
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
return { facts, complete: parsed.complete && !cut && !unread, available: true };
|
|
246
|
+
}
|
package/src/memory/capture.ts
CHANGED
|
@@ -38,7 +38,7 @@ export class Capture {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
observe(call: { tool: string; args: string; output: string; id: string; paths: string[]; sourceId?: string }): void {
|
|
41
|
-
if (!this.sessionId || ((MEMORY_READ_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || BOUNDED_TASK_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || CAPABILITY_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || STUDY_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || HANDOFF_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, ""))) || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_memory_correct") || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_source_read" || this.opts.skip?.includes(call.tool)) return;
|
|
41
|
+
if (!this.sessionId || ((MEMORY_READ_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || BOUNDED_TASK_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || CAPABILITY_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || STUDY_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || HANDOFF_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, ""))) || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_memory_correct") || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_source_read" || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_set_title" || this.opts.skip?.includes(call.tool)) return;
|
|
42
42
|
// Anything that names a denylisted path is kept out of memory entirely, input and output alike.
|
|
43
43
|
if (call.paths.some((p) => isDenied(p, this.opts.deny) || p.split(/\s+/).some((word) => isDenied(word, this.opts.deny)))) return;
|
|
44
44
|
// ponytail: legacy callers without a host sourceId dedupe native ids within a summary interval; all live LocalPeer calls supply invocation identity. Require that port for other hosts.
|
package/src/pi/extension.ts
CHANGED
|
@@ -23,6 +23,8 @@ let shellSeq = 0;
|
|
|
23
23
|
let forcedFailure = "";
|
|
24
24
|
let turnGeneration = 0;
|
|
25
25
|
let sessionId = "";
|
|
26
|
+
let titleRevision = 0;
|
|
27
|
+
let titleEpoch = "";
|
|
26
28
|
let pendingPromptId: string | undefined;
|
|
27
29
|
let activePromptId: string | undefined;
|
|
28
30
|
let settlement: Promise<void> | undefined;
|
|
@@ -93,11 +95,36 @@ async function poll(pi: ExtensionAPI): Promise<void> {
|
|
|
93
95
|
await post("/ack", { id: command.id, ok: false, error: "Pi prompt insertion did not synchronously claim a native turn", refusal: { kind: "prompt_uncertain", promptId: command.id, sessionId, generation: turnGeneration } });
|
|
94
96
|
continue;
|
|
95
97
|
}
|
|
98
|
+
} else if (command.type === "set_session_title") {
|
|
99
|
+
const refuse = (cause: string, error: string) => post("/ack", { id: command.id, ok: true, result: { ok: false, reason: "invalid", cause, error, sessionId, titleEpoch, titleRevision } });
|
|
100
|
+
const nativeId = runtimeCtx?.sessionManager?.getHeader?.()?.id;
|
|
101
|
+
if (command.sessionId !== sessionId || nativeId !== sessionId || command.titleEpoch !== undefined && command.titleEpoch !== titleEpoch) {
|
|
102
|
+
await refuse("session", "Pi native session changed before the title write; check ahub status and reconnect the current session before retrying."); continue;
|
|
103
|
+
}
|
|
104
|
+
if (command.generation !== turnGeneration) {
|
|
105
|
+
await refuse("generation", "Pi native turn changed before the title write; wait for the current turn and check ahub status before retrying."); continue;
|
|
106
|
+
}
|
|
107
|
+
if (typeof command.title !== "string" || !command.title.trim() || [...command.title].length > 80 || typeof command.onlyIfUnnamed !== "boolean") {
|
|
108
|
+
await refuse("arguments", "Pi title command arguments are invalid; use a 1-80 character normalized title and a conditional-write flag before retrying."); continue;
|
|
109
|
+
}
|
|
110
|
+
if (typeof pi.getSessionName !== "function" || typeof pi.setSessionName !== "function") {
|
|
111
|
+
await refuse("native_unavailable", "Pi native title API is unavailable; restart Pi with the supported hub extension and check ahub status before retrying."); continue;
|
|
112
|
+
}
|
|
113
|
+
// The native read and synchronous write share one JS turn: a manual rename always wins against the agent.
|
|
114
|
+
const before = pi.getSessionName() ?? "";
|
|
115
|
+
if (command.onlyIfUnnamed && before) {
|
|
116
|
+
await post("/ack", { id: command.id, ok: true, result: { ok: false, reason: "already_named", error: "This session already has a title; keep it or ask the person to rename it with ahub title.", sessionId, titleEpoch, titleRevision, sessionName: before } }); continue;
|
|
117
|
+
}
|
|
118
|
+
pi.setSessionName(command.title);
|
|
119
|
+
const sessionName = pi.getSessionName() ?? "";
|
|
120
|
+
await post("/ack", { id: command.id, ok: true, result: { ok: sessionName === command.title, ...(sessionName === command.title ? {} : { reason: "invalid", cause: "native_readback", mayHaveApplied: true, error: "Pi native title readback did not confirm the write; the title may have been applied; check ahub status before retrying." }), sessionId, titleEpoch, titleRevision, sessionName } });
|
|
121
|
+
continue;
|
|
96
122
|
} else if (command.type === "get_session_state") {
|
|
97
123
|
const entries = runtimeCtx.sessionManager.getEntries();
|
|
98
124
|
await post("/ack", { id: command.id, ok: true, result: {
|
|
99
125
|
sessionId: runtimeCtx.sessionManager.getHeader()?.id,
|
|
100
126
|
sessionFile: runtimeCtx.sessionManager.getSessionFile(),
|
|
127
|
+
...(typeof pi.getSessionName === "function" ? { sessionName: pi.getSessionName() ?? "", titleEpoch, titleRevision } : {}),
|
|
101
128
|
idle: runtimeCtx.isIdle() === true,
|
|
102
129
|
empty: entries.every((entry: any) => ["model_change", "thinking_level_change"].includes(entry.type)),
|
|
103
130
|
} });
|
|
@@ -120,7 +147,14 @@ async function poll(pi: ExtensionAPI): Promise<void> {
|
|
|
120
147
|
}
|
|
121
148
|
}
|
|
122
149
|
await post("/ack", { id: command.id, ok: true });
|
|
123
|
-
} catch (error) {
|
|
150
|
+
} catch (error) {
|
|
151
|
+
if (command.type === "set_session_title") {
|
|
152
|
+
await post("/ack", { id: command.id, ok: true, result: { ok: false, reason: "invalid", cause: "native_failed", mayHaveApplied: true, error: "Pi native title API failed without confirming the write; the title may have been applied; check ahub status before retrying.", sessionId, titleEpoch, titleRevision } });
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (pendingPromptId === command.id) pendingPromptId = undefined;
|
|
156
|
+
await post("/ack", { id: command.id, ok: false, error: (error as Error).message });
|
|
157
|
+
}
|
|
124
158
|
} catch { await new Promise((resolve) => setTimeout(resolve, 250)); }
|
|
125
159
|
}
|
|
126
160
|
}
|
|
@@ -142,8 +176,9 @@ export default function(pi: ExtensionAPI): void {
|
|
|
142
176
|
shutdown = ctx.shutdown;
|
|
143
177
|
const state = ctx.sessionManager.getHeader();
|
|
144
178
|
sessionId = String(state?.id ?? "");
|
|
179
|
+
titleRevision = 0; titleEpoch = crypto.randomUUID();
|
|
145
180
|
try {
|
|
146
|
-
const claimed = await post("/event", { type: "session_start", ownerToken, pid: process.pid, signature: processSignature(process.pid), sessionId: state?.id, sessionFile: ctx.sessionManager.getSessionFile(), approvalTurnAbort: typeof ctx.abort === "function", fencedPrompt: true });
|
|
181
|
+
const claimed = await post("/event", { type: "session_start", ownerToken, pid: process.pid, signature: processSignature(process.pid), ...(typeof pi.getSessionName === "function" ? { sessionName: pi.getSessionName() ?? "", titleEpoch, titleRevision } : {}), sessionId: state?.id, sessionFile: ctx.sessionManager.getSessionFile(), approvalTurnAbort: typeof ctx.abort === "function", fencedPrompt: true });
|
|
147
182
|
if (claimed?.ok === false) { ctx.shutdown?.(); return; }
|
|
148
183
|
} catch (error) { ctx.shutdown?.(); throw error; }
|
|
149
184
|
if (!pollStarted) { pollStarted = true; void poll(pi); }
|
|
@@ -168,8 +203,13 @@ export default function(pi: ExtensionAPI): void {
|
|
|
168
203
|
lastActivity = now;
|
|
169
204
|
try { await post("/event", { type: "activity" }); } catch { /* shutdown owns bridge cleanup */ }
|
|
170
205
|
});
|
|
206
|
+
pi.on("session_info_changed", async () => {
|
|
207
|
+
if (runtimeCtx?.sessionManager?.getHeader?.()?.id !== sessionId || typeof pi.getSessionName !== "function") return;
|
|
208
|
+
try { await post("/event", { type: "session_title", sessionId, titleEpoch, titleRevision: ++titleRevision, sessionName: pi.getSessionName() ?? "" }); }
|
|
209
|
+
catch { /* title observation must not disrupt the native session */ }
|
|
210
|
+
});
|
|
171
211
|
// Native retries/continuations emit more starts before the one logical agent_settled.
|
|
172
|
-
pi.on("agent_start", async () => { toolSteps = 0; forcedFailure = ""; turnGeneration++; activePromptId = pendingPromptId ?? activePromptId; pendingPromptId = undefined; await post("/event", { type: "agent_start", generation: turnGeneration, ...(activePromptId ? { promptId: activePromptId } : {}) }); });
|
|
212
|
+
pi.on("agent_start", async () => { toolSteps = 0; forcedFailure = ""; turnGeneration++; activePromptId = pendingPromptId ?? activePromptId; pendingPromptId = undefined; await post("/event", { type: "agent_start", sessionId, ...(typeof pi.getSessionName === "function" ? { sessionName: pi.getSessionName() ?? "", titleEpoch, titleRevision } : {}), generation: turnGeneration, ...(activePromptId ? { promptId: activePromptId } : {}) }); });
|
|
173
213
|
pi.on("model_select", async (_event: any, ctx: any) => { if (ctx.model?.provider && ctx.model.provider !== "agent-hub-local") ctx.shutdown?.(); });
|
|
174
214
|
pi.on("before_agent_start", async (event: any, ctx: any) => {
|
|
175
215
|
if (ctx.model?.provider && ctx.model.provider !== "agent-hub-local") { ctx.abort?.(); return { systemPrompt: event.systemPrompt }; }
|