@staix/agent-hub 0.12.24 → 0.12.26
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 +17 -1
- package/README.md +2 -2
- package/docs/agent-notes/adapters.md +2 -0
- package/docs/agent-notes/tasks.md +4 -0
- package/docs/agent-notes/tests.md +3 -0
- package/docs/data-policy.md +3 -2
- package/docs/memory.md +45 -8
- package/docs/operations.md +24 -14
- package/docs/quickstart.md +2 -2
- package/docs/smoke.md +16 -10
- package/docs/specs/2026-09-19-agent-hub-design.md +33 -1
- package/docs/text-projection.md +2 -2
- package/docs/verification/2026-10-14-0.12.24-audit.md +123 -0
- package/docs/verification/2026-10-14-0.12.25.md +27 -0
- package/docs/verification/2026-10-14-0.12.26.md +31 -0
- package/docs/verified.json +11 -11
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1 -1
- package/src/adapters/pi.ts +79 -5
- package/src/cli/help.ts +1 -1
- package/src/cli/identity.ts +1 -1
- package/src/cli/memory.ts +16 -6
- package/src/hub/daemon.ts +7 -3
- package/src/hub/path-policy.ts +89 -11
- package/src/hub/tasks.ts +17 -6
- package/src/hub/text-projection.ts +38 -11
- package/src/hub/tooling.ts +19 -8
- package/src/memory/import.ts +52 -17
- package/src/memory/store.ts +62 -7
- package/src/pi/extension.ts +40 -5
- package/src/ui/index.html +5 -4
package/src/hub/tasks.ts
CHANGED
|
@@ -564,6 +564,10 @@ export class Tasks {
|
|
|
564
564
|
private plannerPullRecord(task: Task): HistoryEntry["planning"] {
|
|
565
565
|
return task.history.findLast(entry => entry.event === "backlog pulled" || entry.event === "backlog returned")?.planning;
|
|
566
566
|
}
|
|
567
|
+
private hasPersonReservation(task: Task): boolean {
|
|
568
|
+
const reservation = task.history.findLast(entry => entry.event === "reserved" || entry.planning?.fields.includes("reserved") && !["backlog pulled", "backlog returned"].includes(entry.event)) ?? (task.history[0]?.event === "backlogged" ? task.history[0] : undefined);
|
|
569
|
+
return !!task.reserved && reservation?.by === USER;
|
|
570
|
+
}
|
|
567
571
|
wip(peer: PeerId, except?: number): number {
|
|
568
572
|
return this.d.board.plannerWip(peer, except);
|
|
569
573
|
}
|
|
@@ -604,8 +608,7 @@ export class Tasks {
|
|
|
604
608
|
}
|
|
605
609
|
const assignment = this.plannerAssignment(task);
|
|
606
610
|
if (!assignment.owner) throw new PreEffectToolRefusal("no peer has eligible planner WIP capacity");
|
|
607
|
-
|
|
608
|
-
if (task.reserved && reservation?.by === USER && assignment.owner !== task.reserved) throw new PreEffectToolRefusal("a person's reservation is retained");
|
|
611
|
+
if (this.hasPersonReservation(task) && assignment.owner !== task.reserved) throw new PreEffectToolRefusal("a person's reservation is retained");
|
|
609
612
|
return { task, owner: assignment.owner };
|
|
610
613
|
};
|
|
611
614
|
const initial = validate(), withheld = await this.withholds("note", input.reason, initial.task);
|
|
@@ -723,6 +726,7 @@ export class Tasks {
|
|
|
723
726
|
if (task.state === "approved") throw new PreEffectToolRefusal("approved work is final");
|
|
724
727
|
if (by !== USER && (task.state !== "backlog" || taskHasStarted(task) || task.pinned || Object.hasOwn(patch, "pinned"))) throw new PreEffectToolRefusal("planner changes only unpinned backlog work");
|
|
725
728
|
if (by !== USER && patch.rank !== undefined && task.history.some(entry => entry.by === USER && entry.planning?.fields.includes("rank"))) throw new PreEffectToolRefusal("a person's backlog order is retained");
|
|
729
|
+
if (by !== USER && Object.hasOwn(patch, "reserved") && this.hasPersonReservation(task)) throw new PreEffectToolRefusal("a person's reservation is retained");
|
|
726
730
|
if (Object.hasOwn(patch, "deps") || Object.hasOwn(patch, "reserved")) {
|
|
727
731
|
if (task.state !== "backlog" || taskHasStarted(task) || task.owner) throw new PreEffectToolRefusal("dependency and reservation changes require unstarted backlog work");
|
|
728
732
|
if (patch.reserved && !(SUPPORTED_PEERS as readonly string[]).includes(patch.reserved) && !this.d.bus.knownPeers().includes(patch.reserved) && !this.d.routing().classes[task.class]?.peers.includes(patch.reserved)) throw new PreEffectToolRefusal("reserved owner is not a supported or configured peer");
|
|
@@ -1611,7 +1615,11 @@ export class Tasks {
|
|
|
1611
1615
|
if (next.phase && !this.isPii(task) && !this.nameable(JSON.stringify(next.phase))) throw new PreEffectToolRefusal("phase metadata matches a PII pattern; use private work");
|
|
1612
1616
|
if (next.phase) validatePhaseLinks(task, next.phase, id => this.d.board.get(id), t => this.isPii(t));
|
|
1613
1617
|
}
|
|
1614
|
-
|
|
1618
|
+
// Recording a head is the reviewer's; a head dropped because the repo or PR changed is not a recording, and a
|
|
1619
|
+
// head supplied with a new PR identity is one even when its value is unchanged (#382).
|
|
1620
|
+
const suppliedHead = !!input && typeof input === "object" && (input as { reviewedHead?: unknown }).reviewedHead !== undefined;
|
|
1621
|
+
const artifactChanged = next.repo !== task.sdlc?.repo || next.pr !== task.sdlc?.pr;
|
|
1622
|
+
if (next.reviewedHead !== undefined && (next.reviewedHead !== task.sdlc?.reviewedHead || (suppliedHead && artifactChanged))) this.mine(task, by, "reviewer", true);
|
|
1615
1623
|
return this.d.board.update(task.id, by, "lifecycle", { sdlc: next });
|
|
1616
1624
|
}
|
|
1617
1625
|
|
|
@@ -1816,7 +1824,7 @@ export class Tasks {
|
|
|
1816
1824
|
}
|
|
1817
1825
|
if (task.state === "proposed" || task.state === "changes_requested") task = this.d.board.update(task.id, by, "accepted", { state: "in_progress" }, WITH_DONE); // done without a separate accept
|
|
1818
1826
|
const command = this.d.runCheck ? this.d.check?.(task.class, task) : undefined;
|
|
1819
|
-
if (!command) return this.complete(task, by, summary, refs, "", withheld);
|
|
1827
|
+
if (!command) return this.complete(task, by, summary, refs, "", withheld, "review");
|
|
1820
1828
|
// The tool call returns now; a check can outlast an agent's tool timeout. The result decides what comes next.
|
|
1821
1829
|
this.checking.set(task.id, task.owner);
|
|
1822
1830
|
const pending = this.d.board.update(task.id, by, "done (checking)", { refs: cleanRefs(refs) }, summary, { withheld: withheld || undefined });
|
|
@@ -1942,12 +1950,15 @@ export class Tasks {
|
|
|
1942
1950
|
this.tell(task, `Task #${id}: its check failed.\n$ ${outcome}${result.tail ? `\n${result.tail}` : ""}\nFix it and call hub_task_done again.`, pii);
|
|
1943
1951
|
}
|
|
1944
1952
|
|
|
1945
|
-
|
|
1953
|
+
/** `gated`: the transition `done()` already gated with no await since, so it is not read back twice (#382). */
|
|
1954
|
+
private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = "", withheld = false, gated?: "review"): Promise<Task> {
|
|
1946
1955
|
let reviewer = task.reviewer;
|
|
1947
|
-
if (this.d.sdlc && this.d.sdlc.config().mode !== "off") {
|
|
1956
|
+
if (this.d.sdlc && this.d.sdlc.config().mode !== "off" && gated !== (reviewer ? "review" : "approval")) {
|
|
1948
1957
|
await this.lifecycleGate(task, reviewer ? "review" : "approval");
|
|
1949
1958
|
task = this.need(task.id, false, true);
|
|
1950
1959
|
this.mine(task, by, "owner", true);
|
|
1960
|
+
// In every mode: another done may have landed during the readback.
|
|
1961
|
+
if (task.state === "in_review" || task.state === "approved") throw new Error(`task #${task.id} is already ${task.state}`);
|
|
1951
1962
|
reviewer = task.reviewer;
|
|
1952
1963
|
}
|
|
1953
1964
|
const next = this.d.board.update(task.id, by, "done", { state: reviewer ? "in_review" : "approved", ...(task.sdlc ? { sdlc: { ...task.sdlc, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), reviewer ? "review" : "merge") } } : {}), refs: cleanRefs(refs) }, checkOutput ? `${summary ?? ""}\n${checkOutput}`.trim() : summary, { withheld: withheld || undefined });
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { closeSync, constants, fstatSync, mkdirSync, mkdtempSync, openSync, readSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { extname, isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
import { dirname, extname, isAbsolute, join, resolve } from "node:path";
|
|
4
4
|
|
|
5
5
|
import { homedir, tmpdir } from "node:os";
|
|
6
6
|
import { sbplString } from "../local/deny.ts";
|
|
@@ -8,6 +8,7 @@ import { spawnSync } from "node:child_process";
|
|
|
8
8
|
import { realPath } from "./project.ts";
|
|
9
9
|
import { profile, sandboxAvailable } from "../local/sandbox.ts";
|
|
10
10
|
export type ProjectionConfig = Record<string, string[]>;
|
|
11
|
+
const EMPTY_ROOT = "/var/empty";
|
|
11
12
|
export const MAX_PROJECTION_INPUT = 16 * 1024 * 1024;
|
|
12
13
|
const MAX_INPUT = MAX_PROJECTION_INPUT, MAX_TEXT = 256 * 1024;
|
|
13
14
|
/** Only machine-local config chooses programs; no implicit shell or command-string parsing. */
|
|
@@ -23,14 +24,18 @@ export function projectionConfig(raw: unknown): ProjectionConfig {
|
|
|
23
24
|
}
|
|
24
25
|
export interface ProjectedText { inputHash: string; rawHash: string; rawBlob: string; configHash: string; text: string }
|
|
25
26
|
const hash = (data: string | Buffer) => createHash("sha256").update(data).digest("hex");
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
/** The program and script files an argv runs, resolved as the converter will see them. */
|
|
28
|
+
function programFiles(cwd: string, argv: string[]): { index: number; file: string; size: number; mtimeMs: number; ctimeMs: number }[] {
|
|
29
|
+
const out: { index: number; file: string; size: number; mtimeMs: number; ctimeMs: number }[] = [];
|
|
28
30
|
for (const [index, arg] of argv.entries()) {
|
|
29
|
-
if (index !== 0 && !/\.(?:sh|py|js|ts|rb)$/.test(arg)) continue;
|
|
31
|
+
if (index !== 0 && !/\.(?:sh|py|js|mjs|cjs|ts|rb|pl)$/.test(arg)) continue;
|
|
30
32
|
const choices = isAbsolute(arg) ? [arg] : index === 0 && !arg.includes("/") ? (process.env.PATH ?? "/usr/bin:/bin").split(":").slice(0, 32).map(dir => join(dir, arg)) : [resolve(cwd, arg)];
|
|
31
|
-
for (const file of choices) try { const st = statSync(file); if (!st.isFile()) continue;
|
|
33
|
+
for (const file of choices) try { const st = statSync(file); if (!st.isFile()) continue; out.push({ index, file, size: st.size, mtimeMs: st.mtimeMs, ctimeMs: st.ctimeMs }); break; } catch { /* another PATH entry */ }
|
|
32
34
|
}
|
|
33
|
-
return
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
function commandFingerprint(cwd: string, argv: string[]): string {
|
|
38
|
+
return hash([JSON.stringify(argv), ...programFiles(cwd, argv).flatMap(p => [p.file, String(p.size), String(p.mtimeMs), String(p.ctimeMs)])].join("\\n"));
|
|
34
39
|
}
|
|
35
40
|
function boundedCache(file: string): string {
|
|
36
41
|
const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
@@ -47,8 +52,14 @@ function boundedCache(file: string): string {
|
|
|
47
52
|
export class TextProjections {
|
|
48
53
|
private readonly sandbox: string;
|
|
49
54
|
private readonly warned = new Set<string>();
|
|
55
|
+
/** Inputs whose full-budget conversion failed: the same bytes under the same command are not converted again. */
|
|
56
|
+
private readonly failed = new Set<string>();
|
|
50
57
|
constructor(private readonly options: { cwd: string; stateDir: string; config: ProjectionConfig; deny?: string[]; readAllow?: string[]; log?: (line: string) => void }) {
|
|
51
|
-
|
|
58
|
+
// Rooted at an empty system directory, not the project. The project guarantee: project file data is denied, then the
|
|
59
|
+
// snapshot, the argv-named program/script files and the directory listings above an in-project script are granted, so
|
|
60
|
+
// no other project file is readable (system, toolchain, home and local.read_allow paths outside the project remain granted).
|
|
61
|
+
// Only where conversion can run: /var/empty is a macOS directory, and a host without the sandbox never converts.
|
|
62
|
+
this.sandbox = Object.keys(options.config).length && sandboxAvailable() ? profile(EMPTY_ROOT, false, options.readAllow, options.deny, ["."]) : "";
|
|
52
63
|
}
|
|
53
64
|
private warn(ext: string): void {
|
|
54
65
|
if (this.warned.has(ext)) return;
|
|
@@ -103,17 +114,33 @@ export class TextProjections {
|
|
|
103
114
|
if (value.inputHash === inputHash && value.rawHash === rawHash && value.rawBlob === rawBlob && value.configHash === configHash && typeof value.text === "string" && Buffer.byteLength(value.text) <= MAX_TEXT) return value;
|
|
104
115
|
}
|
|
105
116
|
} catch { /* missing/invalid cache is not evidence */ }
|
|
117
|
+
if (this.failed.has(key)) throw new Error("conversion failed before");
|
|
106
118
|
if (!sandboxAvailable()) throw new Error("sandbox unavailable");
|
|
107
119
|
scratch = realPath(mkdtempSync(join(tmpdir(), "ahub-projection-input-"))); const snapshot = join(scratch, "input" + ext);
|
|
108
120
|
writeFileSync(snapshot, bytes, { mode: 0o400 });
|
|
121
|
+
// A read_allow entry may enclose the project. Seatbelt lets a later rule win only for the operation it names, so the
|
|
122
|
+
// deny and the literal grants after it both name file-read-data. The converter runs in its scratch directory (getcwd
|
|
123
|
+
// needs that directory's data), with its program and script arguments resolved to absolute paths.
|
|
124
|
+
const root = realPath(this.options.cwd), projectDeny = "\n(deny file-read-data (subpath " + sbplString(root) + "))";
|
|
125
|
+
const resolved = programFiles(this.options.cwd, argv);
|
|
126
|
+
// A module loader lists the directories above its script: grant those listings (not their files) up to the root.
|
|
127
|
+
const listings = new Set<string>();
|
|
128
|
+
for (const program of resolved) for (let dir = dirname(realPath(program.file)); dir.startsWith(root + "/") || dir === root; dir = dirname(dir)) { listings.add(dir); if (dir === root) break; }
|
|
129
|
+
const programs = resolved.map(p => "\n(allow file-read* file-read-data process-exec (literal " + sbplString(realPath(p.file)) + "))").join("")
|
|
130
|
+
+ [...listings].map(dir => "\n(allow file-read-data (literal " + sbplString(dir) + "))").join("")
|
|
131
|
+
+ "\n(allow file-read* file-read-data (literal " + sbplString(scratch) + "))";
|
|
132
|
+
const absolute = new Map(resolved.map(p => [p.index, p.file]));
|
|
109
133
|
const result = spawnSync(process.execPath, [join(import.meta.dir, "projection-exec.ts")], {
|
|
110
|
-
cwd:
|
|
111
|
-
input: JSON.stringify({ cwd:
|
|
134
|
+
cwd: scratch, timeout: deadline === undefined ? 8000 : Math.ceil(Math.max(1, Math.min(8000, deadline - performance.now()))), maxBuffer: MAX_TEXT * 2, encoding: "utf8",
|
|
135
|
+
input: JSON.stringify({ cwd: scratch, sandbox: this.sandbox + projectDeny + "\n(allow file-read* file-read-data (literal " + sbplString(snapshot) + "))" + programs, argv: argv.map((arg, index) => arg === "{input}" ? snapshot : absolute.get(index) ?? arg), ...(deadline === undefined ? {} : { timeoutMs: Math.max(1, Math.min(5000, deadline - performance.now() - 100)) }) }),
|
|
112
136
|
env: { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: homedir(), LANG: "en_US.UTF-8", TERM: "dumb" },
|
|
113
137
|
});
|
|
114
|
-
|
|
138
|
+
// ponytail: in-memory for this process, cleared at 1024 keys; persist it if restarts make retries costly.
|
|
139
|
+
const failure = () => { if (deadline === undefined) { if (this.failed.size >= 1024) this.failed.clear(); this.failed.add(key); } return new Error("conversion failed"); };
|
|
140
|
+
if (result.error) throw new Error("conversion helper failed"); // a spawn or helper failure may be transient: not remembered
|
|
141
|
+
if (result.status !== 0) throw failure();
|
|
115
142
|
const converted = JSON.parse(result.stdout) as { ok?: boolean; text?: string };
|
|
116
|
-
if (converted.ok !== true || typeof converted.text !== "string" || Buffer.byteLength(converted.text) > MAX_TEXT || converted.text.includes("\0")) throw
|
|
143
|
+
if (converted.ok !== true || typeof converted.text !== "string" || Buffer.byteLength(converted.text) > MAX_TEXT || converted.text.includes("\0")) throw failure();
|
|
117
144
|
verify?.();
|
|
118
145
|
const value = { inputHash, rawHash, rawBlob, configHash, text: converted.text };
|
|
119
146
|
mkdirSync(cacheDir, { recursive: true });
|
package/src/hub/tooling.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
-
import { accessSync, constants, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { accessSync, closeSync, constants, fstatSync, openSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { delimiter, join, relative, resolve, isAbsolute } from "node:path";
|
|
5
5
|
import { realPath } from "./project.ts";
|
|
@@ -32,8 +32,18 @@ const PROBE_MS = 2000, PROBE_BYTES = 4096;
|
|
|
32
32
|
/** `env` supplies CLAUDE_CONFIG_DIR and CODEX_HOME, the runtimes' own config roots (the hub's environment by default). */
|
|
33
33
|
export interface ToolingSources { home?: string; cwd?: string; path?: string; env?: Record<string, string | undefined> }
|
|
34
34
|
|
|
35
|
+
/** A regular file of at most 1 MiB, opened without blocking: a FIFO or device under a project's `.claude/` reads as absent. */
|
|
36
|
+
const text = (file: string): string | undefined => {
|
|
37
|
+
let fd: number | undefined;
|
|
38
|
+
try {
|
|
39
|
+
fd = openSync(file, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
40
|
+
const st = fstatSync(fd);
|
|
41
|
+
if (!st.isFile() || st.size > 1024 * 1024) return undefined;
|
|
42
|
+
return readFileSync(fd, "utf8");
|
|
43
|
+
} catch { return undefined; } finally { if (fd !== undefined) closeSync(fd); }
|
|
44
|
+
};
|
|
35
45
|
const json = (file: string): Record<string, unknown> | undefined => {
|
|
36
|
-
try { const v = JSON.parse(
|
|
46
|
+
try { const v = JSON.parse(text(file) ?? ""); return v && typeof v === "object" ? v as Record<string, unknown> : undefined; } catch { return undefined; }
|
|
37
47
|
};
|
|
38
48
|
/** A version string as data, never anything longer or stranger. */
|
|
39
49
|
const version = (v: unknown): string | null => typeof v === "string" && /^[0-9A-Za-z.+_-]{1,40}$/.test(v) ? v : null;
|
|
@@ -58,12 +68,13 @@ function claudePlugins(root: string, cwd: string | undefined): Addon[] {
|
|
|
58
68
|
}
|
|
59
69
|
|
|
60
70
|
function codexPlugins(root: string): Addon[] {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const found = new Map<string, string>(); // plugin name -> marketplace
|
|
64
|
-
// `[plugins."name@market"]` with no `enabled = false` below it, and plugins whose hooks Codex was told to trust
|
|
65
|
-
|
|
66
|
-
for (const m of
|
|
71
|
+
const config = text(join(root, "config.toml"));
|
|
72
|
+
if (config === undefined) return [];
|
|
73
|
+
const found = new Map<string, string>(), disabled = new Set<string>(); // plugin name -> marketplace
|
|
74
|
+
// `[plugins."name@market"]` with no `enabled = false` below it, and plugins whose hooks Codex was told to trust
|
|
75
|
+
// unless their plugin entry disables them: a trust entry outlives the plugin's own switch.
|
|
76
|
+
for (const m of config.matchAll(/^\[plugins\."([^"@]+)@([^"]+)"\]\s*\n((?:(?!\[)[^\n]*\n?)*)/gm)) if (!/^\s*enabled\s*=\s*false\b/m.test(m[3] ?? "")) found.set(m[1]!, m[2]!); else disabled.add(m[1]! + "@" + m[2]!);
|
|
77
|
+
for (const m of config.matchAll(/^\[hooks\.state\."([^"@]+)@([^":]+):/gm)) if (!disabled.has(m[1]! + "@" + m[2]!)) found.set(m[1]!, m[2]!);
|
|
67
78
|
const out: Addon[] = [];
|
|
68
79
|
for (const [plugin, market] of found) {
|
|
69
80
|
const name = PLUGINS.get(plugin);
|
package/src/memory/import.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
|
-
import { existsSync } from "node:fs";
|
|
2
|
+
import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
4
|
+
import { realPath } from "../hub/project.ts";
|
|
3
5
|
import { homedir } from "node:os";
|
|
4
6
|
import { join } from "node:path";
|
|
5
7
|
import { KINDS, looksSecret, type Kind, type MemoryStore } from "./store.ts";
|
|
@@ -9,7 +11,24 @@ export const CLAUDE_MEM_BUSY_TIMEOUT_MS = 3000;
|
|
|
9
11
|
/** Titles `ahub ask --remember` gives a model's answer (ask.ts ASK_NOTE_TITLE): never imported as a finding. */
|
|
10
12
|
const MODEL_ANSWER = "ahub ask (model answer)";
|
|
11
13
|
|
|
12
|
-
export interface ImportResult { read: number; added: number; skipped: { model: number; pii: number; secret: number;
|
|
14
|
+
export interface ImportResult { read: number; added: number; skipped: { model: number; pii: number; secret: number }; more: boolean; cursor: number }
|
|
15
|
+
|
|
16
|
+
/** Machine-local progress is disposable; malformed/unreadable state simply replays the idempotent import. */
|
|
17
|
+
function readCursors(file: string): Record<string, number> {
|
|
18
|
+
try {
|
|
19
|
+
const value: unknown = JSON.parse(readFileSync(file, "utf8"));
|
|
20
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
21
|
+
return Object.fromEntries(Object.entries(value).filter(([, id]) => Number.isSafeInteger(id) && id >= 0));
|
|
22
|
+
} catch { return {}; }
|
|
23
|
+
}
|
|
24
|
+
function writeCursor(file: string, key: string, cursor: number, log: (line: string) => void): void {
|
|
25
|
+
const temp = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
26
|
+
try {
|
|
27
|
+
writeFileSync(temp, JSON.stringify({ ...readCursors(file), [key]: cursor }) + "\n", { mode: 0o600, flag: "wx" });
|
|
28
|
+
renameSync(temp, file);
|
|
29
|
+
} catch { log("claude-mem import progress could not be saved; the next run may replay handled rows"); }
|
|
30
|
+
finally { try { rmSync(temp, { force: true }); } catch { /* disposable progress */ } }
|
|
31
|
+
}
|
|
13
32
|
|
|
14
33
|
/**
|
|
15
34
|
* #258 phase 3: copy the hub's own notes from claude-mem (observations whose metadata names a `peer`: `hub_remember`
|
|
@@ -17,30 +36,38 @@ export interface ImportResult { read: number; added: number; skipped: { model: n
|
|
|
17
36
|
* the projects in `chain` (claude-mem files them under the repository's basename). Model answers, PII-pattern text and secret-looking text are skipped.
|
|
18
37
|
* Idempotent: an observation always maps to the same entry.
|
|
19
38
|
*/
|
|
20
|
-
export function importClaudeMem(store: MemoryStore, opts: { project: string; chain: string[]; isPii: (text: string) => boolean; nodes?: (text: string) => string[]; db?: string; limit?: number }): ImportResult {
|
|
39
|
+
export function importClaudeMem(store: MemoryStore, opts: { project: string; chain: string[]; isPii: (text: string) => boolean; nodes?: (text: string) => string[]; db?: string; limit?: number; restart?: boolean; log?: (line: string) => void }): ImportResult {
|
|
21
40
|
const file = opts.db ?? join(homedir(), ".claude-mem", "claude-mem.db");
|
|
22
41
|
if (!existsSync(file)) throw new Error(`no claude-mem store at ${file}`);
|
|
42
|
+
const limit = opts.limit ?? 10_000;
|
|
43
|
+
if (!Number.isSafeInteger(limit) || limit <= 0) throw new Error("--limit takes a positive integer");
|
|
44
|
+
const state = join(store.dir, "import-state.json");
|
|
45
|
+
const name = opts.chain.at(-1) ?? "";
|
|
46
|
+
const key = createHash("sha256").update(JSON.stringify([realPath(file), name, opts.project])).digest("hex");
|
|
47
|
+
let cursor = opts.restart ? 0 : readCursors(state)[key] ?? 0;
|
|
23
48
|
const db = new Database(file, { readonly: true });
|
|
24
49
|
db.run(`PRAGMA busy_timeout = ${CLAUDE_MEM_BUSY_TIMEOUT_MS}`);
|
|
25
|
-
const result: ImportResult = { read: 0, added: 0, skipped: { model: 0, pii: 0, secret: 0,
|
|
50
|
+
const result: ImportResult = { read: 0, added: 0, skipped: { model: 0, pii: 0, secret: 0 }, more: false, cursor };
|
|
26
51
|
try {
|
|
27
|
-
const
|
|
28
|
-
|
|
52
|
+
const max = db.query("SELECT MAX(id) AS id FROM observations WHERE project = ? AND metadata LIKE '%\"peer\"%'").get(name) as { id: number | null };
|
|
53
|
+
if ((max.id ?? 0) < cursor) cursor = 0;
|
|
54
|
+
const rows = db.query("SELECT id, project, title, text, narrative, created_at_epoch, metadata FROM observations WHERE project = ? AND id > ? AND metadata LIKE '%\"peer\"%' ORDER BY id LIMIT ?")
|
|
55
|
+
.all(name, cursor, limit) as { id: number; project: string | null; title: string | null; text: string | null; narrative: string | null; created_at_epoch: number | null; metadata: string | null }[];
|
|
29
56
|
// The hub files its claude-mem notes under the repository's own name (the chain's last); the names above it are other
|
|
30
57
|
// projects (a superproject) whose notes and privacy policy are not this one's. ponytail: claude-mem keys projects by
|
|
31
58
|
// basename, so two unrelated repositories with one name still share it.
|
|
32
|
-
const project = opts.project
|
|
33
|
-
|
|
34
|
-
|
|
59
|
+
const project = opts.project;
|
|
60
|
+
result.read = rows.length;
|
|
61
|
+
result.more = rows.length === limit;
|
|
62
|
+
const handle = (r: typeof rows[number]) => {
|
|
35
63
|
let meta: Record<string, unknown>;
|
|
36
|
-
try { meta = JSON.parse(r.metadata ?? "{}"); } catch {
|
|
37
|
-
if (typeof meta.peer !== "string" || !meta.peer)
|
|
38
|
-
if (!r.project || !names.has(r.project)) { result.skipped.project++; continue; }
|
|
64
|
+
try { meta = JSON.parse(r.metadata ?? "{}"); } catch { return; }
|
|
65
|
+
if (!meta || typeof meta !== "object" || typeof meta.peer !== "string" || !meta.peer) return;
|
|
39
66
|
const title = r.title ?? "", text = (r.text ?? r.narrative ?? "").trim();
|
|
40
|
-
if (!text)
|
|
41
|
-
if (meta.source === "ahub ask" || title.startsWith(MODEL_ANSWER)) { result.skipped.model++;
|
|
42
|
-
if (opts.isPii(`${title}\n${text}`)) { result.skipped.pii++;
|
|
43
|
-
if (looksSecret(`${title}\n${text}`)) { result.skipped.secret++;
|
|
67
|
+
if (!text) return;
|
|
68
|
+
if (meta.source === "ahub ask" || title.startsWith(MODEL_ANSWER)) { result.skipped.model++; return; }
|
|
69
|
+
if (opts.isPii(`${title}\n${text}`)) { result.skipped.pii++; return; }
|
|
70
|
+
if (looksSecret(`${title}\n${text}`)) { result.skipped.secret++; return; }
|
|
44
71
|
const kind: Kind = KINDS.includes(meta.kind as Kind) ? meta.kind as Kind : "finding";
|
|
45
72
|
const at = new Date(r.created_at_epoch ?? 0).toISOString();
|
|
46
73
|
const { added } = store.importNote({ op: "note", project, scope: "project", tier: "long", kind, peer: meta.peer.slice(0, 32), ...(title ? { title } : {}), text,
|
|
@@ -48,8 +75,16 @@ export function importClaudeMem(store: MemoryStore, opts: { project: string; cha
|
|
|
48
75
|
// own paths and symbols place the note in the graph.
|
|
49
76
|
about: opts.nodes?.(`${title}\n${text}`) ?? [], source: "claude-mem" }, at);
|
|
50
77
|
if (added) result.added++;
|
|
78
|
+
};
|
|
79
|
+
for (const row of rows) {
|
|
80
|
+
handle(row);
|
|
81
|
+
cursor = row.id; // only fully handled rows, including every skip
|
|
51
82
|
}
|
|
52
|
-
} finally {
|
|
83
|
+
} finally {
|
|
84
|
+
db.close();
|
|
85
|
+
result.cursor = cursor;
|
|
86
|
+
writeCursor(state, key, cursor, opts.log ?? console.error);
|
|
87
|
+
}
|
|
53
88
|
store.refresh();
|
|
54
89
|
return result;
|
|
55
90
|
}
|
package/src/memory/store.ts
CHANGED
|
@@ -78,10 +78,10 @@ const SECRETS = [/-----BEGIN [A-Z ]*PRIVATE KEY-----/, /\bAKIA[0-9A-Z]{16}\b/, /
|
|
|
78
78
|
export const looksSecret = (text: string): boolean => SECRETS.some((re) => re.test(text));
|
|
79
79
|
|
|
80
80
|
/** `task`: the board task's id and proposal time; its node carries both, so a task id reused after `ahub reset --all` is another node. */
|
|
81
|
-
export interface Extract { cwd: string; project: string; repo: string; deny?: string[]; paths?: string[]; symbols?: string[]; task?: { id: number; created: number } }
|
|
81
|
+
export interface Extract { cwd: string; project: string; repo: string; deny?: string[]; paths?: string[]; symbols?: string[]; task?: { id: number; created: number }; checkExists?: boolean }
|
|
82
82
|
/**
|
|
83
83
|
* Deterministic edges, fixed at write time so a rebuild never depends on the file system: the task's refs and plan
|
|
84
|
-
* paths, plan symbols (with the path when the plan names exactly one), paths in the text that exist in the project then,
|
|
84
|
+
* paths, plan symbols (with the path when the plan names exactly one), paths in the text that exist in the project then (or text-only paths when checkExists is false),
|
|
85
85
|
* `#N` as an issue of `repo`, and `task #N` as a board task. Paths stay relative to the project root; denylisted ones
|
|
86
86
|
* are left out.
|
|
87
87
|
*/
|
|
@@ -99,7 +99,7 @@ export function extractNodes(text: string, x: Extract): Node[] {
|
|
|
99
99
|
for (const m of text.matchAll(/(?:^|[\s(`'"])((?:[\w.-]+\/)*[\w.-]+\.[A-Za-z0-9]{1,8})(?=$|[\s)`'",:;])/g)) {
|
|
100
100
|
const p = m[1]!;
|
|
101
101
|
if (!p.includes("/") && !/\.(ts|js|tsx|jsx|py|rs|go|md|json|toml|yaml|yml|sh|html|css)$/.test(p)) continue;
|
|
102
|
-
try { if (!isAbsolute(p) && !p.startsWith("..") && existsSync(join(x.cwd, p))) path(p); } catch { /* unreadable: not an edge */ }
|
|
102
|
+
try { if (!isAbsolute(p) && !p.startsWith("..") && (x.checkExists === false || existsSync(join(x.cwd, p)))) path(p); } catch { /* unreadable: not an edge */ }
|
|
103
103
|
}
|
|
104
104
|
for (const m of text.matchAll(/\btask #(\d{1,9})\b/gi)) out.add(`task:${x.project}#${m[1]}`);
|
|
105
105
|
for (const m of text.matchAll(/(?<![\w/]|task )#(\d{1,9})\b/gi)) out.add(`issue:${x.repo}#${m[1]}`);
|
|
@@ -244,7 +244,7 @@ export class MemoryStore {
|
|
|
244
244
|
// A fixed machine: the same observation imported on two machines is one entry once they sync.
|
|
245
245
|
const body = { schema: MEMORY_SCHEMA, at, machine: "claude-mem", ...n, text: n.text.slice(0, MAX_TEXT), ...(n.title ? { title: n.title.slice(0, MAX_TITLE) } : {}) } as Omit<Entry, "id">;
|
|
246
246
|
const id = entryId(body);
|
|
247
|
-
// The
|
|
247
|
+
// The source tuple also recognizes legacy ids whose nodes depended on the checkout or extractor version.
|
|
248
248
|
const known = this.db.query("SELECT id FROM entries WHERE id = ? OR (source = 'claude-mem' AND project = ? AND at = ? AND text = ?)").get(id, n.project, at, n.text.slice(0, MAX_TEXT)) as { id: string } | null;
|
|
249
249
|
if (known) return { id: known.id, added: false };
|
|
250
250
|
appendFileSync(join(this.dir, n.tier === "short" ? "local" : "log", `${this.machine.id}.jsonl`), JSON.stringify({ ...body, id }) + "\n", { mode: 0o600 });
|
|
@@ -255,7 +255,55 @@ export class MemoryStore {
|
|
|
255
255
|
private op(op: OpEntry["op"], target: View, actor: string, by?: string): string {
|
|
256
256
|
return this.append({ op, target: target.id, actor, ...(by ? { by } : {}) }, target.local);
|
|
257
257
|
}
|
|
258
|
-
forget(id: string, actor: string, project?: string): View | undefined {
|
|
258
|
+
forget(id: string, actor: string, project?: string): View | undefined {
|
|
259
|
+
const v = this.get(id, project);
|
|
260
|
+
if (!v) return undefined;
|
|
261
|
+
// Legacy imports may have different node-derived ids. Forget every known copy, including hidden ones.
|
|
262
|
+
const group = v.source === "claude-mem" ? this.live().filter(x => x.source === v.source && x.project === v.project && x.at === v.at && x.text === v.text) : [v];
|
|
263
|
+
for (const member of group) this.op("forget", member, actor);
|
|
264
|
+
return v;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Plan or append reconciliation of legacy imports, preserving every existing replacement relation. */
|
|
268
|
+
dedupe(project: string, apply = false): { groups: { members: View[]; heads: string[]; target: string }[]; applied: number } {
|
|
269
|
+
const rows = this.live(), byId = new Map(rows.map(v => [v.id, v]));
|
|
270
|
+
const tuples = new Map<string, View[]>();
|
|
271
|
+
for (const v of rows) {
|
|
272
|
+
if (v.project !== project || v.source !== "claude-mem") continue;
|
|
273
|
+
const key = JSON.stringify([v.at, v.text]);
|
|
274
|
+
const group = tuples.get(key) ?? []; group.push(v); tuples.set(key, group);
|
|
275
|
+
}
|
|
276
|
+
const groups: { members: View[]; heads: string[]; target: string }[] = [];
|
|
277
|
+
for (const members of tuples.values()) {
|
|
278
|
+
if (members.length < 2) continue;
|
|
279
|
+
members.sort((a, b) => a.id.localeCompare(b.id));
|
|
280
|
+
const heads = members.filter(v => !v.supersededBy);
|
|
281
|
+
if (!heads.length) continue;
|
|
282
|
+
const ids = new Set(members.map(v => v.id));
|
|
283
|
+
const terminals = new Map<string, View>();
|
|
284
|
+
for (const v of members) {
|
|
285
|
+
let terminal = v;
|
|
286
|
+
const seen = new Set<string>();
|
|
287
|
+
while (terminal.supersededBy && !seen.has(terminal.id)) {
|
|
288
|
+
seen.add(terminal.id);
|
|
289
|
+
const next = byId.get(terminal.supersededBy);
|
|
290
|
+
if (!next) break;
|
|
291
|
+
terminal = next;
|
|
292
|
+
}
|
|
293
|
+
if (!terminal.supersededBy && !ids.has(terminal.id) && !terminal.local && (terminal.project === project || terminal.scope === "shared") && heads.every(h => h.scope !== "shared" || terminal.scope === "shared")) terminals.set(terminal.id, terminal);
|
|
294
|
+
}
|
|
295
|
+
const outside = [...terminals.values()].sort((a, b) => b.at.localeCompare(a.at) || b.id.localeCompare(a.id))[0];
|
|
296
|
+
if (!outside && heads.length === 1) continue;
|
|
297
|
+
groups.push({ members, heads: heads.map(v => v.id), target: outside?.id ?? heads[0]!.id });
|
|
298
|
+
}
|
|
299
|
+
let applied = 0;
|
|
300
|
+
if (apply) for (const group of groups) for (const head of group.heads) {
|
|
301
|
+
if (head === group.target) continue;
|
|
302
|
+
if (!this.supersede(head, group.target, "user", project)) throw new Error("memory dedupe replacement refused; rerun to inspect current relations");
|
|
303
|
+
applied++;
|
|
304
|
+
}
|
|
305
|
+
return { groups, applied };
|
|
306
|
+
}
|
|
259
307
|
/** A short-term note kept short-term: it expires like any other and is never promoted (its demote entry stays local). */
|
|
260
308
|
keepShort(id: string, actor: string): void { const v = this.get(id); if (v?.tier === "short" && v.local && v.expiresAt === null) this.op("demote", v, actor, this.until()); } // once: a deadline is never pushed out
|
|
261
309
|
demote(id: string, actor: string, project?: string): View | undefined { const v = this.get(id, project); if (v?.tier === "long") this.op("demote", v, actor, this.until()); return v; }
|
|
@@ -533,13 +581,18 @@ export class MemoryStore {
|
|
|
533
581
|
return gone.size;
|
|
534
582
|
}
|
|
535
583
|
|
|
584
|
+
/** Index deferred writes before any note, goal or suggestion read. */
|
|
585
|
+
private ensureFresh(): void {
|
|
586
|
+
// Another process (a CLI, another project's hub, a sync) may have written since this one last read the segments.
|
|
587
|
+
if (this.stale || this.shape() !== this.seen) try { this.stale = false; this.refresh(); } catch { this.stale = true; /* still busy: the next read tries again */ }
|
|
588
|
+
}
|
|
589
|
+
|
|
536
590
|
/**
|
|
537
591
|
* What a read sees: rows that expired and promotions that became due while the store sat idle are settled first, so a
|
|
538
592
|
* read, and the index after it, never differ from a rebuild.
|
|
539
593
|
*/
|
|
540
594
|
private live(): View[] {
|
|
541
|
-
|
|
542
|
-
if (this.stale || this.shape() !== this.seen) try { this.stale = false; this.refresh(); } catch { this.stale = true; /* still busy: the next read tries again */ }
|
|
595
|
+
this.ensureFresh();
|
|
543
596
|
this.prune(); this.promoteHeld(); return this.views();
|
|
544
597
|
}
|
|
545
598
|
/** A write whose index update failed: the next read refreshes first. */
|
|
@@ -677,6 +730,7 @@ export class MemoryStore {
|
|
|
677
730
|
|
|
678
731
|
/** Every long-term task with its derived state, links and lease, newest first; `project` keeps its own and shared ones. */
|
|
679
732
|
goals(project?: string): GoalView[] {
|
|
733
|
+
this.ensureFresh();
|
|
680
734
|
const now = this.now();
|
|
681
735
|
const rows = this.db.query("SELECT * FROM goals").all() as { id: string; at: string; machine: string; project: string; scope: string; title: string; milestones: string; depends: string }[];
|
|
682
736
|
const ops = this.db.query("SELECT op, at, machine, target, by FROM ops WHERE op IN ('goal_state', 'goal_link', 'lease', 'lease_force') ORDER BY at, id").all() as { op: string; at: string; machine: string; target: string; by: string | null }[];
|
|
@@ -726,6 +780,7 @@ export class MemoryStore {
|
|
|
726
780
|
return s;
|
|
727
781
|
}
|
|
728
782
|
suggestions(project?: string): Suggestion[] {
|
|
783
|
+
this.ensureFresh();
|
|
729
784
|
const visible = project ? new Set(this.list({ project, limit: Number.MAX_SAFE_INTEGER, includeSuperseded: true }).map((v) => v.id)) : undefined;
|
|
730
785
|
const ops = this.db.query("SELECT id, op, at, target, by FROM ops WHERE op IN ('suggest', 'confirm', 'reject') ORDER BY at, id").all() as { id: string; op: string; at: string; target: string; by: string | null }[];
|
|
731
786
|
const verdict = new Map<string, "confirmed" | "rejected">();
|
package/src/pi/extension.ts
CHANGED
|
@@ -23,6 +23,9 @@ let shellSeq = 0;
|
|
|
23
23
|
let forcedFailure = "";
|
|
24
24
|
let turnGeneration = 0;
|
|
25
25
|
let sessionId = "";
|
|
26
|
+
let pendingPromptId: string | undefined;
|
|
27
|
+
let activePromptId: string | undefined;
|
|
28
|
+
let settlement: Promise<void> | undefined;
|
|
26
29
|
const maxSteps = Number(process.env.AGENTHUB_PI_MAX_STEPS ?? 30);
|
|
27
30
|
let shutdown: (() => void) | undefined;
|
|
28
31
|
let runtimeCtx: any;
|
|
@@ -66,7 +69,30 @@ async function poll(pi: ExtensionAPI): Promise<void> {
|
|
|
66
69
|
if (!command) continue;
|
|
67
70
|
try {
|
|
68
71
|
if (command.type === "prompt" || command.type === "steer") {
|
|
72
|
+
if (command.type === "prompt") {
|
|
73
|
+
// Let our settled callback return to native Pi before the normal queued successor starts.
|
|
74
|
+
if (settlement) {
|
|
75
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
76
|
+
try { await Promise.race([settlement, new Promise<void>(resolve => { timer = setTimeout(resolve, 5_000); })]); }
|
|
77
|
+
finally { clearTimeout(timer); }
|
|
78
|
+
}
|
|
79
|
+
if (settlement || runtimeCtx?.isIdle?.() !== true || (command.sessionId !== undefined && command.sessionId !== sessionId) || (command.generation !== undefined && command.generation !== turnGeneration)) {
|
|
80
|
+
await post("/ack", { id: command.id, ok: false, error: "Pi prompt was not inserted because its native session is no longer idle at the expected generation", refusal: { kind: "prompt_not_started", promptId: command.id, sessionId, generation: turnGeneration } });
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
// Pinned Pi 1.0.1 starts _runAgentPrompt synchronously before its first await.
|
|
84
|
+
// Keep this check and insertion in one JS turn; snapshots cannot reserve native idleness.
|
|
85
|
+
pendingPromptId = command.id;
|
|
86
|
+
}
|
|
69
87
|
pi.sendMessage({ customType: "agent-hub", content: String(command.message ?? ""), display: true }, { triggerTurn: command.type === "prompt", deliverAs: command.type === "steer" ? "steer" : "followUp" });
|
|
88
|
+
if (command.type === "prompt" && runtimeCtx?.isIdle?.() !== false) {
|
|
89
|
+
// Another extension's async settled hook can make native Pi defer this insertion.
|
|
90
|
+
// Our callback barrier is not an atomic native reservation: keep that effect uncertain.
|
|
91
|
+
if (pendingPromptId === command.id) pendingPromptId = undefined;
|
|
92
|
+
if (activePromptId === command.id) activePromptId = undefined;
|
|
93
|
+
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
|
+
continue;
|
|
95
|
+
}
|
|
70
96
|
} else if (command.type === "get_session_state") {
|
|
71
97
|
const entries = runtimeCtx.sessionManager.getEntries();
|
|
72
98
|
await post("/ack", { id: command.id, ok: true, result: {
|
|
@@ -94,7 +120,7 @@ async function poll(pi: ExtensionAPI): Promise<void> {
|
|
|
94
120
|
}
|
|
95
121
|
}
|
|
96
122
|
await post("/ack", { id: command.id, ok: true });
|
|
97
|
-
} catch (error) { await post("/ack", { id: command.id, ok: false, error: (error as Error).message }); }
|
|
123
|
+
} catch (error) { if (pendingPromptId === command.id) pendingPromptId = undefined; await post("/ack", { id: command.id, ok: false, error: (error as Error).message }); }
|
|
98
124
|
} catch { await new Promise((resolve) => setTimeout(resolve, 250)); }
|
|
99
125
|
}
|
|
100
126
|
}
|
|
@@ -117,7 +143,7 @@ export default function(pi: ExtensionAPI): void {
|
|
|
117
143
|
const state = ctx.sessionManager.getHeader();
|
|
118
144
|
sessionId = String(state?.id ?? "");
|
|
119
145
|
try {
|
|
120
|
-
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" });
|
|
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 });
|
|
121
147
|
if (claimed?.ok === false) { ctx.shutdown?.(); return; }
|
|
122
148
|
} catch (error) { ctx.shutdown?.(); throw error; }
|
|
123
149
|
if (!pollStarted) { pollStarted = true; void poll(pi); }
|
|
@@ -127,7 +153,7 @@ export default function(pi: ExtensionAPI): void {
|
|
|
127
153
|
const text = message?.content?.filter((c: any) => c?.type === "text").map((c: any) => c.text).join("")?.trim();
|
|
128
154
|
const failed = !!forcedFailure || message?.stopReason === "error";
|
|
129
155
|
const cancelled = !forcedFailure && message?.stopReason === "aborted";
|
|
130
|
-
await post("/event", { type: "agent_end", generation: turnGeneration, text: text ?? "", failed, cancelled, ...(failed ? { error: forcedFailure || message?.errorMessage || message?.stopReason } : {}) });
|
|
156
|
+
await post("/event", { type: "agent_end", generation: turnGeneration, ...(activePromptId ? { promptId: activePromptId } : {}), text: text ?? "", failed, cancelled, ...(failed ? { error: forcedFailure || message?.errorMessage || message?.stopReason } : {}) });
|
|
131
157
|
});
|
|
132
158
|
pi.on("message_end", async (event: any) => {
|
|
133
159
|
const tokens = assistantTokens(event.message);
|
|
@@ -142,7 +168,8 @@ export default function(pi: ExtensionAPI): void {
|
|
|
142
168
|
lastActivity = now;
|
|
143
169
|
try { await post("/event", { type: "activity" }); } catch { /* shutdown owns bridge cleanup */ }
|
|
144
170
|
});
|
|
145
|
-
|
|
171
|
+
// 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 } : {}) }); });
|
|
146
173
|
pi.on("model_select", async (_event: any, ctx: any) => { if (ctx.model?.provider && ctx.model.provider !== "agent-hub-local") ctx.shutdown?.(); });
|
|
147
174
|
pi.on("before_agent_start", async (event: any, ctx: any) => {
|
|
148
175
|
if (ctx.model?.provider && ctx.model.provider !== "agent-hub-local") { ctx.abort?.(); return { systemPrompt: event.systemPrompt }; }
|
|
@@ -157,10 +184,18 @@ export default function(pi: ExtensionAPI): void {
|
|
|
157
184
|
return undefined;
|
|
158
185
|
});
|
|
159
186
|
pi.on("agent_settled", async (_event: any, ctx: any) => {
|
|
187
|
+
let release!: () => void;
|
|
188
|
+
const current = new Promise<void>(resolve => { release = resolve; });
|
|
189
|
+
settlement = current;
|
|
160
190
|
const entries = ctx.sessionManager?.getEntries?.() ?? [];
|
|
161
191
|
const message = entries.slice().reverse().find((entry: any) => entry.type === "message" && entry.message?.role === "assistant")?.message;
|
|
162
192
|
const text = message?.content?.filter((c: any) => c?.type === "text").map((c: any) => c.text).join("")?.trim();
|
|
163
|
-
await post("/event", { type: "agent_settled", generation: turnGeneration, ...(text ? { text } : {}) });
|
|
193
|
+
try { await post("/event", { type: "agent_settled", generation: turnGeneration, ...(activePromptId ? { promptId: activePromptId } : {}), ...(text ? { text } : {}) }); }
|
|
194
|
+
finally {
|
|
195
|
+
activePromptId = undefined;
|
|
196
|
+
// Release after this callback returns, not inside its awaited POST continuation.
|
|
197
|
+
setTimeout(() => { if (settlement === current) settlement = undefined; release(); }, 0);
|
|
198
|
+
}
|
|
164
199
|
});
|
|
165
200
|
pi.on("user_bash", async (event: any) => {
|
|
166
201
|
const generation = turnGeneration, callSessionId = sessionId;
|
package/src/ui/index.html
CHANGED
|
@@ -515,7 +515,7 @@ function dropTarget(node, accepted, payload) {
|
|
|
515
515
|
void action({...payload, id:card.id});
|
|
516
516
|
});
|
|
517
517
|
}
|
|
518
|
-
function taskCard(task, card, stage, ids) {
|
|
518
|
+
function taskCard(task, card, stage, ids, overdue) {
|
|
519
519
|
const row = el('article', undefined, 'task-card'), head = el('div', undefined, 'row-head');
|
|
520
520
|
row.tabIndex = 0; taskRows.set(task.id,row);
|
|
521
521
|
const trackCard = on => { const sceneCard=officeCards.get(task.id);if(sceneCard)sceneCard.classList.toggle('office-spotlight',on); };
|
|
@@ -539,7 +539,7 @@ function taskCard(task, card, stage, ids) {
|
|
|
539
539
|
if(task.external?.provider==='github'){const source=el('a','GitHub #'+task.external.issue);source.href=task.external.url;source.rel='noreferrer';source.target='_blank';row.append(source);}
|
|
540
540
|
if(task.pinned)row.append(el('p','Pinned','meta'));
|
|
541
541
|
if(task.plannedStart != null || task.due != null)row.append(el('p','Planned '+dateText(task.plannedStart)+' / due '+dateText(task.due),'meta'));
|
|
542
|
-
if(
|
|
542
|
+
if(overdue)row.append(badge('Overdue','warn'));
|
|
543
543
|
if(task.actualDates)row.append(el('p','Actual start '+dateText(task.actualDates.startedAt)+' / review '+dateText(task.actualDates.reviewAt)+' / done '+dateText(task.actualDates.doneAt),'meta'));
|
|
544
544
|
|
|
545
545
|
const controls = el('div', undefined, 'actions');
|
|
@@ -567,6 +567,7 @@ function renderBoard(snapshot, peers, ids) {
|
|
|
567
567
|
renderTaskProgress(progress);
|
|
568
568
|
const stages = new Map(progress.stages.map(stage=>[stage.id,stage])), byId = new Map(snapshot.tasks.map(task=>[task.id,task]));
|
|
569
569
|
const cards = new Map(model.columns.flatMap(column=>[...column.ready,...column.waiting]).map(card=>[card.id,card]));
|
|
570
|
+
const now = Date.now(), overdue = new Set(snapshot.tasks.filter(task=>task.due != null && task.due<now && task.state!=='approved').map(task=>task.id));
|
|
570
571
|
boardViewState={snapshot,peers,ids,cards};
|
|
571
572
|
renderPlanning(snapshot);
|
|
572
573
|
const staged=$('backlog-proposal');staged.disabled=snapshot.boardCapabilities?.backlog!==true || !sameBoardContext(boardContext());
|
|
@@ -589,7 +590,7 @@ function renderBoard(snapshot, peers, ids) {
|
|
|
589
590
|
}
|
|
590
591
|
if (!ids.length) empty(target,'No peer status available.');
|
|
591
592
|
});
|
|
592
|
-
update('tasks',[snapshot.tasks,peers,snapshot.boardCapabilities,taskFilter,ownerFilter,releaseFilter,lanes,showAllDone],target=>{
|
|
593
|
+
update('tasks',[snapshot.tasks,peers,snapshot.boardCapabilities,taskFilter,ownerFilter,releaseFilter,lanes,showAllDone,[...overdue]],target=>{
|
|
593
594
|
taskRows.clear();
|
|
594
595
|
const visible=snapshot.tasks.filter(task=>(!taskFilter || (taskFilter==='work' ? ['in_progress','changes_requested'].includes(task.state) : task.state===taskFilter)) && (!ownerFilter || task.owner===ownerFilter || task.reviewer===ownerFilter) && (!releaseFilter || task.release===Number(releaseFilter)));
|
|
595
596
|
const visibleIds=new Set(visible.map(task=>task.id));
|
|
@@ -614,7 +615,7 @@ function renderBoard(snapshot, peers, ids) {
|
|
|
614
615
|
if(hidden) ready=ready.slice(0,10);
|
|
615
616
|
for(const [label,list] of [['Ready',ready],['Waiting',waiting]]) {
|
|
616
617
|
if(column.id==='todo') box.append(el('h4',label+' ('+list.length+')'));
|
|
617
|
-
for(const card of list) box.append(taskCard(byId.get(card.id),card,stages.get(card.id),eligible));
|
|
618
|
+
for(const card of list) box.append(taskCard(byId.get(card.id),card,stages.get(card.id),eligible,overdue.has(card.id)));
|
|
618
619
|
}
|
|
619
620
|
if(!ready.length && !waiting.length) empty(box,'No tasks');
|
|
620
621
|
if(hidden){const more=el('button','Show '+hidden+' older Done tasks');more.type='button';more.addEventListener('click',()=>{if(!boardViewState)return;showAllDone=true;renderBoard(boardViewState.snapshot,boardViewState.peers,boardViewState.ids)});box.append(more)}
|