@staix/agent-hub 0.28.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 +22 -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 +4 -1
- package/docs/agent-notes/local-worker.md +3 -1
- package/docs/agent-notes/tasks.md +7 -3
- package/docs/agent-notes/tests.md +3 -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 +19 -1
- package/docs/dogfood.md +13 -7
- package/docs/operations.md +177 -20
- package/docs/quickstart.md +18 -3
- package/docs/sdlc.md +2 -2
- package/docs/security.md +45 -4
- 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.29.0.md +59 -0
- 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-control-write-stall-windows.json +241 -0
- package/docs/verification/2026-11-03-control-write-stall-windows.md +20 -0
- package/docs/verification/2026-11-03-grok-1.0.62-t0.md +150 -0
- package/docs/verification/2026-11-03-live-board-latency.json +556 -0
- package/docs/verification/2026-11-03-live-board-latency.md +64 -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 +715 -45
- 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 +3 -1
- package/src/cli/identity.ts +8 -1
- package/src/cli/main.ts +39 -0
- package/src/cli/output.ts +5 -0
- package/src/cli/recovery-package.ts +9 -0
- package/src/cli/worktree-tidy.ts +158 -0
- package/src/cli/worktrees-output.ts +43 -0
- package/src/hub/board.ts +31 -5
- 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/control-client.ts +16 -1
- package/src/hub/control-write-trace.ts +409 -0
- package/src/hub/daemon.ts +408 -66
- package/src/hub/dogfood-read.ts +62 -9
- package/src/hub/hub-tools.ts +12 -6
- 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/routing.ts +9 -2
- package/src/hub/runtime-capture-prune.ts +4 -1
- package/src/hub/sdlc-release-authorization.ts +13 -4
- package/src/hub/sdlc-release.ts +23 -10
- package/src/hub/sdlc.ts +15 -2
- package/src/hub/settings.ts +3 -0
- package/src/hub/task-worktree.ts +331 -0
- package/src/hub/tasks.ts +192 -25
- 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/local/deny.ts +49 -0
- package/src/local/tools.ts +4 -2
- package/src/memory/brief.ts +5 -0
- package/src/memory/capture.ts +1 -1
- package/src/memory/client.ts +5 -2
- 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/local/deny.ts
CHANGED
|
@@ -77,6 +77,54 @@ const hooksUnder = (parent: string): string => {
|
|
|
77
77
|
return depth > MAX_ANCHOR_DEPTH ? `^.*/${hfsFoldedName("hooks")}(/|$)` : `^${"/[^/]+".repeat(depth)}/${hfsFoldedName("hooks")}(/|$)`;
|
|
78
78
|
};
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* #1123: a sandboxed command may not move what a branch, HEAD or a linked worktree points at. `refs/**` (branches, tags, `refs/stash`), `packed-refs*`, `HEAD*` (its lock too) and
|
|
82
|
+
* `worktrees/**` (a linked worktree's admin dir: its HEAD, index, logs) are refused directly under every plain `.git` and under each external git dir (the common dir and a linked
|
|
83
|
+
* worktree's own, whatever they are named). The hub's own git runs outside the sandbox and is unaffected, so #1115's hub commit still lands.
|
|
84
|
+
*
|
|
85
|
+
* Every name must sit DIRECTLY under its git dir. An unanchored `refs` or `worktrees` filter would also match an ANCESTOR of the project with that name, and a project under
|
|
86
|
+
* `~/worktrees/` would lose every `.git` write (the #520 `hooks` lesson). So each rule is adjacent by construction: one literal for `.git` + a short name, or, for the long names
|
|
87
|
+
* (`worktrees`, `packed-refs`, where `.git` + name would pass the 1024-byte literal limit), a front half and a back half that must both match, or a depth anchor for an external dir.
|
|
88
|
+
* A submodule's git dir (`.git/modules/<name>`, nested ones, and `.git/worktrees/<wt>/modules/<name>` for a linked worktree) is covered the same way (`subDirs`, the discovered
|
|
89
|
+
* ones: a git dir that is not discovered is refused whole by the #518/#520 rules), per the person's "deny .git writes" decision: nothing is listed as a residual.
|
|
90
|
+
*/
|
|
91
|
+
const refDepth = (d: string): number => d.split("/").filter(Boolean).length;
|
|
92
|
+
/**
|
|
93
|
+
* `^(/[^/]+){depth}/<name>(/|$)`: `name` as the entry directly under `d`, the depth written out because seatbelt ignores `{n}`. The folded names cost (measured, bytes of regex): `refs` 334,
|
|
94
|
+
* `worktrees` 669, so past the depth where the path plus the name would near 1024 it falls back to the unanchored name, which over-refuses only under an ancestor with that name and
|
|
95
|
+
* never under-protects.
|
|
96
|
+
*/
|
|
97
|
+
const refUnder = (d: string, name: string): string => {
|
|
98
|
+
const depth = refDepth(d), cost = Buffer.byteLength(hfsFoldedName(name)) + depth * 6 + 8;
|
|
99
|
+
return cost > 1000 ? `^.*/${hfsFoldedName(name)}(/|$)` : `^${"/[^/]+".repeat(depth)}/${hfsFoldedName(name)}(/|$)`;
|
|
100
|
+
};
|
|
101
|
+
function gitRefRules(gitDirs: string[], subDirs: string[] = []): string[] {
|
|
102
|
+
const git = hfsFoldedName(".git");
|
|
103
|
+
const re = (s: string) => `(regex ${sbplString(s)})`;
|
|
104
|
+
const name = (n: string) => hfsFoldedName(n);
|
|
105
|
+
// A linked worktree's own submodule git dirs (`worktrees/<wt>/modules/**`) are governed by the #518/#520 rules, not these.
|
|
106
|
+
const plainSub = `(require-not ${re(`^.*/${git}/worktrees/[^/]+/modules(/|$)`)})`;
|
|
107
|
+
const plain = [
|
|
108
|
+
// `.git` (335 bytes) + `HEAD` or `refs` (334) fit one literal: adjacent by construction. `HEAD.lock` goes with `HEAD`.
|
|
109
|
+
re(`^.*/${git}/${name("HEAD")}(\\.lock)?$`),
|
|
110
|
+
re(`^.*/${git}/${name("refs")}(/|$)`),
|
|
111
|
+
// `.git` + `packed-refs` (803) and `.git` + `worktrees` (669) do not fit one literal. The front literal pins the start of the name right under a `.git`, and for `worktrees` the
|
|
112
|
+
// back literal pins its end; `packed` alone covers `packed-refs` and its lock/new files, which is the intent.
|
|
113
|
+
re(`^.*/${git}/${name("packed")}`),
|
|
114
|
+
`(require-all ${re(`^.*/${git}/${name("work")}`)} ${re(`^.*${name("trees")}(/|$)`)} ${plainSub})`,
|
|
115
|
+
];
|
|
116
|
+
const external = [...gitDirs, ...subDirs].flatMap((d) => {
|
|
117
|
+
const own = sbplString(d), direct = re(`^${sbplEscape(d)}/[^/]+$`);
|
|
118
|
+
return [
|
|
119
|
+
`(require-all ${direct} ${re(`^.*/${name("HEAD")}(\\.lock)?$`)})`,
|
|
120
|
+
`(require-all ${direct} ${re(`^.*/${name("packed")}`)})`,
|
|
121
|
+
`(require-all (subpath ${own}) ${re(refUnder(d, "refs"))})`,
|
|
122
|
+
`(require-all (subpath ${own}) ${re(refUnder(d, "worktrees"))} (require-not ${re(`^${sbplEscape(d)}/worktrees/[^/]+/modules(/|$)`)}))`,
|
|
123
|
+
];
|
|
124
|
+
});
|
|
125
|
+
return [...plain, ...external];
|
|
126
|
+
}
|
|
127
|
+
|
|
80
128
|
export function hubWriteRegexes(root: string, gitDirs: string[] = [], moduleGit: readonly { root: string; entries: string[]; dirs?: string[] }[] = [], worktreesRoot?: string): string[] {
|
|
81
129
|
const git = hfsFoldedName(".git");
|
|
82
130
|
const re = (s: string) => `(regex ${sbplString(s)})`;
|
|
@@ -157,6 +205,7 @@ export function hubWriteRegexes(root: string, gitDirs: string[] = [], moduleGit:
|
|
|
157
205
|
`(require-all ${re(`^.*/${git}/[^/]+$`)} ${CONFIG_WORKTREE_NAME.map(re).join(" ")})`,
|
|
158
206
|
re(`^.*/${git}/${hfsFoldedName("hooks")}(/|$)`),
|
|
159
207
|
`(require-all ${re(`^.*/${git}/.*$`)} ${re(`^.*/${hfsFoldedName("commondir")}$`)})`,
|
|
208
|
+
...gitRefRules(gitDirs, allDirs),
|
|
160
209
|
...modules,
|
|
161
210
|
...siblings,
|
|
162
211
|
...external,
|
package/src/local/tools.ts
CHANGED
|
@@ -117,7 +117,7 @@ export const TOOL_SCHEMAS = [
|
|
|
117
117
|
fn("write", "Create or overwrite a file inside the project. Needs the user's approval.", { path: str, content: str }, ["path", "content"]),
|
|
118
118
|
fn("edit", "Replace one exact, unique occurrence of `old` with `new` in a file. Needs the user's approval.", { path: str, old: str, new: str }, ["path", "old", "new"]),
|
|
119
119
|
fn("bash", "Run a shell command in the project directory, sandboxed: no writes outside the project, no credential reads, no network unless the hub enables it. Follows hub permission policy: proven bounded read-only commands may run automatically in ask-when-needed; other commands ask.", { command: str, timeout_s: { type: "integer", minimum: 1, maximum: 600 } }, ["command"]),
|
|
120
|
-
fn("git", "Run git in the project. status, diff, log, show, ls-files, blame, rev-parse run freely; add, commit, restore, checkout, switch, stash, mv, rm, branch, merge, rebase, cherry-pick, revert, tag need approval; everything else (push, fetch, config, remote ...) is refused.", { args: { type: "array", items: str, minItems: 1 } }, ["args"]),
|
|
120
|
+
fn("git", "Run git in the project. status, diff, log, show, ls-files, blame, rev-parse run freely; add, commit, restore, checkout, switch, stash, mv, rm, branch, merge, rebase, cherry-pick, revert, tag need approval; everything else (push, fetch, config, remote ...) is refused. The sandbox does not let you move a branch, tag or HEAD anywhere (commit, branch, tag, stash, switch, merge, rebase and reset fail, submodules included), nor stage with add inside a linked worktree: edit files, and the hub commits and pushes them when you call hub_task_done.", { args: { type: "array", items: str, minItems: 1 } }, ["args"]),
|
|
121
121
|
fn("hub_send", "Send a message to the other agents now, before your turn ends. Conclusions only. Your final answer is shared anyway; use this for something that cannot wait.", { text: str, to: { type: "array", items: str } }, ["text"]),
|
|
122
122
|
];
|
|
123
123
|
|
|
@@ -235,7 +235,9 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
|
|
|
235
235
|
// Sandboxed like bash: flags such as --output or an editor cannot write outside the project or reach the network.
|
|
236
236
|
const res = await sandboxedExec(["git", "--no-pager", ...args], { cwd: ctx.cwd, profile: ctx.sandboxProfile, readOnlyData: !!ctx.readOnly?.length, ...(ctx.sandboxEnv ? { env: ctx.sandboxEnv } : {}), ...(ctx.signal ? { signal: ctx.signal } : {}) });
|
|
237
237
|
try { ctx.onProcessExit?.(res.code); } catch { /* optional status observer never changes a tool result */ }
|
|
238
|
-
|
|
238
|
+
// #1123: the profile refuses writes to refs, HEAD, packed-refs and a linked worktree's admin dir, so a commit, branch, tag, stash or switch cannot succeed here. Say so, with the next step.
|
|
239
|
+
const refused = res.code !== 0 && /(?:\.git\/(?:refs|HEAD|packed-refs|worktrees)\b|cannot lock ref|index\.lock)[^\n]*(?:Operation not permitted|not permitted)/i.test(res.output);
|
|
240
|
+
return `${res.output}${refused ? "\nnote: the sandbox does not let you move a branch, tag or HEAD, or stage inside a linked worktree (#1123). Leave your edits in the working tree and finish with hub_task_done. For implementation work the hub stages, commits and pushes them for you; for work that changes no files, or a private task, there is nothing for it to commit, so do not try to commit it yourself." : ""}\n(exit ${res.code})`;
|
|
239
241
|
}
|
|
240
242
|
case "doc_read": {
|
|
241
243
|
if (!ctx.docRead) return "error: doc_read is not enabled for this project (local.doc_read in .agenthub/config.local.json)";
|
package/src/memory/brief.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { buildIntentBrief, type IntentBriefOptions, type IntentBriefSource } from "@staix/agent-mem";
|
|
2
|
+
import { controlWriteSpan } from "../hub/control-write-trace.ts";
|
|
2
3
|
import type { PeerId } from "../hub/envelope.ts";
|
|
3
4
|
import type { MemoryClient } from "./client.ts";
|
|
4
5
|
|
|
@@ -70,6 +71,10 @@ export class Briefs {
|
|
|
70
71
|
) {}
|
|
71
72
|
|
|
72
73
|
async forTask(peer: PeerId, task: BriefTask, priority: readonly NativeItem[] = []): Promise<string | undefined> {
|
|
74
|
+
return controlWriteSpan("memory", () => this.forTaskBody(peer, task, priority));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private async forTaskBody(peer: PeerId, task: BriefTask, priority: readonly NativeItem[]): Promise<string | undefined> {
|
|
73
78
|
// A brief snapshots its configured cap once, before either optional source is selected.
|
|
74
79
|
const maximum = typeof this.maxItems === "function" ? this.maxItems() : this.maxItems;
|
|
75
80
|
let corpusRevision: string | undefined;
|
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/memory/client.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import packageJson from "../../package.json";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
+
import { controlWriteSpan } from "../hub/control-write-trace.ts";
|
|
3
4
|
import { CaptureStore, SearchIndex, looksSecret, memoryBase, readCaptureConfig, type CaptureOptions as StoreCaptureOptions, type MemoryStore, type CandidateStore, type View } from "@staix/agent-mem";
|
|
4
5
|
|
|
5
6
|
export interface CaptureEvent {
|
|
@@ -54,8 +55,10 @@ export class MemoryClient {
|
|
|
54
55
|
}
|
|
55
56
|
close(): void { this.closed = true; this.releaseCapture(); }
|
|
56
57
|
private run<T>(work: (o: MemoryClientOptions) => T): T | undefined {
|
|
57
|
-
|
|
58
|
-
|
|
58
|
+
return controlWriteSpan("memory", () => {
|
|
59
|
+
try { if (!this.options || this.closed) return undefined; this.options.check(); return work(this.options); }
|
|
60
|
+
catch { this.releaseCapture(); this.options?.log?.("amem operation unavailable"); return undefined; }
|
|
61
|
+
});
|
|
59
62
|
}
|
|
60
63
|
private rows(o: MemoryClientOptions): View[] {
|
|
61
64
|
const store = o.store;
|
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 }; }
|