@staix/agent-hub 0.12.22 → 0.12.24
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 +117 -0
- package/README.md +22 -9
- package/docs/addons/claude-mem.md +24 -0
- package/docs/addons/codex-plugin.md +8 -0
- package/docs/addons/dot-admit.md +8 -0
- package/docs/addons/open-code-review.md +8 -0
- package/docs/addons/ponytail.md +23 -0
- package/docs/addons/ripwire.md +22 -0
- package/docs/agent-notes/README.md +5 -0
- package/docs/agent-notes/adapters.md +18 -6
- package/docs/agent-notes/benchmarks.md +3 -0
- package/docs/agent-notes/budget.md +4 -0
- package/docs/agent-notes/bus.md +3 -1
- package/docs/agent-notes/daemon.md +14 -1
- package/docs/agent-notes/local-worker.md +6 -3
- package/docs/agent-notes/tasks.md +6 -1
- package/docs/agent-notes/tests.md +4 -1
- package/docs/bench.md +28 -3
- package/docs/cooperbench.md +4 -0
- package/docs/data-policy.md +36 -0
- package/docs/events.md +10 -3
- package/docs/gsd-collaboration-analysis.md +48 -0
- package/docs/memory.md +231 -0
- package/docs/operations.md +873 -30
- package/docs/plans/2026-10-11-issues-109-sequential.md +81 -0
- package/docs/plans/2026-10-11-routing-evaluation-proposal.md +30 -0
- package/docs/profiles.md +57 -0
- package/docs/quickstart.md +9 -4
- package/docs/research.md +31 -1
- package/docs/roles.md +85 -0
- package/docs/sdlc.md +170 -0
- package/docs/security.md +124 -8
- package/docs/smoke.md +354 -5
- package/docs/specs/2026-09-19-agent-hub-design.md +676 -15
- package/docs/specs/2026-10-12-backlog-implementation-plan.md +186 -0
- package/docs/specs/2026-10-12-office-view-design.md +212 -0
- package/docs/text-projection.md +19 -0
- package/docs/verification/2026-10-10-cli-output-phase1.md +309 -0
- package/docs/verification/2026-10-10-cli-output-phase2.md +672 -0
- package/docs/verification/2026-10-11-0.12.23.md +90 -0
- package/docs/verification/2026-10-11-acp-watch-t0.md +33 -0
- package/docs/verification/2026-10-11-agent-config-inventory.md +20 -0
- package/docs/verification/2026-10-11-jcode-peer-contract.md +27 -0
- package/docs/verification/2026-10-13-0.12.24.md +94 -0
- package/docs/verification/2026-10-13-board-phone.md +31 -0
- package/docs/verification/2026-10-13-fsmonitor-fixture-attribution.md +19 -0
- package/docs/verified.json +34 -16
- package/package.json +2 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/ROLE-NOTICE.md +26 -0
- package/plugins/agent-hub/server.js +1108 -261
- package/scripts/check-docs.mjs +129 -0
- package/src/adapters/acp-quota.ts +13 -0
- package/src/adapters/acp.ts +201 -20
- package/src/adapters/claude-channel.ts +48 -11
- package/src/adapters/codex-appserver.ts +54 -13
- package/src/adapters/jcode.ts +110 -0
- package/src/adapters/local-worker.ts +10 -7
- package/src/adapters/pi.ts +94 -20
- package/src/cli/attention.ts +60 -0
- package/src/cli/bench.ts +26 -6
- package/src/cli/board-read.ts +12 -0
- package/src/cli/console-state.ts +131 -39
- package/src/cli/console.ts +73 -17
- package/src/cli/facts-hook.ts +12 -4
- package/src/cli/help.ts +52 -21
- package/src/cli/identity.ts +19 -8
- package/src/cli/init.ts +48 -10
- package/src/cli/jcode-mcp.ts +58 -0
- package/src/cli/kimi-mcp.ts +53 -0
- package/src/cli/launch.ts +16 -1
- package/src/cli/main.ts +641 -193
- package/src/cli/memory.ts +193 -0
- package/src/cli/output.ts +404 -0
- package/src/cli/pet-config.ts +1 -0
- package/src/cli/pet.ts +83 -0
- package/src/cli/preview.ts +40 -6
- package/src/cli/reset.ts +3 -0
- package/src/cli/status-lines.ts +1 -29
- package/src/cli/tail-render.ts +1 -0
- package/src/cli/terminal-recovery.ts +27 -1
- package/src/cli/upgrade-runtime.ts +56 -10
- package/src/cli/upgrade.ts +23 -5
- package/src/cli/watch.ts +31 -0
- package/src/hub/activity.ts +53 -0
- package/src/hub/ask.ts +10 -5
- package/src/hub/bench.ts +10 -3
- package/src/hub/board.ts +224 -16
- package/src/hub/budget.ts +39 -7
- package/src/hub/bus.ts +58 -20
- package/src/hub/child-process.ts +11 -0
- package/src/hub/citations.ts +7 -0
- package/src/hub/conductor.ts +34 -17
- package/src/hub/config-trust.ts +20 -7
- package/src/hub/conflicts.ts +2 -0
- package/src/hub/context-window.ts +8 -1
- package/src/hub/crash.ts +11 -1
- package/src/hub/daemon.ts +1218 -113
- package/src/hub/db-timeout.ts +3 -0
- package/src/hub/delivery-journal.ts +7 -1
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +14 -2
- package/src/hub/execution-budget.ts +2 -0
- package/src/hub/facts.ts +28 -8
- package/src/hub/git.ts +38 -0
- package/src/hub/github-import.ts +28 -0
- package/src/hub/hub-tools.ts +49 -10
- package/src/hub/inference.ts +1 -1
- package/src/hub/lifecycle-schema.ts +17 -0
- package/src/hub/lifecycle.ts +10 -4
- package/src/hub/listen.ts +15 -0
- package/src/hub/manager.ts +26 -6
- package/src/hub/path-policy.ts +96 -0
- package/src/hub/peer-catalog.ts +28 -0
- package/src/hub/peers.ts +5 -3
- package/src/hub/permission-mode.ts +33 -5
- package/src/hub/planning.ts +70 -0
- package/src/hub/profiles.ts +74 -0
- package/src/hub/project.ts +2 -1
- package/src/hub/projected-diff.ts +73 -0
- package/src/hub/projection-exec.ts +24 -0
- package/src/hub/recovery-error.ts +31 -0
- package/src/hub/recovery-store.ts +6 -5
- package/src/hub/registry.ts +2 -1
- package/src/hub/research.ts +144 -21
- package/src/hub/roles.ts +123 -0
- package/src/hub/roster.ts +23 -0
- package/src/hub/routing.ts +132 -18
- package/src/hub/sdlc-queue.ts +112 -0
- package/src/hub/sdlc-release.ts +125 -0
- package/src/hub/sdlc.ts +250 -0
- package/src/hub/settings.ts +411 -0
- package/src/hub/shipped-roles.ts +25 -0
- package/src/hub/snapshots.ts +92 -16
- package/src/hub/start-mode.ts +50 -0
- package/src/hub/supervision.ts +1 -0
- package/src/hub/task-sweep.ts +1 -1
- package/src/hub/tasks.ts +1014 -111
- package/src/hub/terminal-open.ts +64 -0
- package/src/hub/text-projection.ts +126 -0
- package/src/hub/tooling.ts +159 -0
- package/src/hub/ui.ts +40 -14
- package/src/hub/workflow.ts +71 -0
- package/src/local/deny.ts +29 -7
- package/src/local/sandbox.ts +50 -10
- package/src/local/tools.ts +11 -6
- package/src/memory/brief.ts +28 -14
- package/src/memory/import.ts +55 -0
- package/src/memory/recall.ts +86 -2
- package/src/memory/store.ts +750 -0
- package/src/memory/sync.ts +145 -0
- package/src/models/mlx.ts +2 -1
- package/src/pet/NOTICE.md +10 -0
- package/src/pet/characters.ts +1438 -0
- package/src/pet/config.ts +31 -0
- package/src/pet/frame.ts +17 -0
- package/src/pet/pixels.ts +28 -0
- package/src/pi/tool-receipts.ts +2 -1
- package/src/role-text.d.ts +1 -0
- package/src/ui/index.html +983 -40
- package/src/ui/needs.ts +58 -0
- package/src/ui/office-model.ts +440 -0
- package/src/ui/peer-palette.ts +16 -0
- package/src/ui/task-board.ts +49 -0
- package/src/ui/task-progress.ts +5 -1
- package/templates/AGENTS.block.md +3 -2
- package/templates/config.json +5 -1
- package/templates/profiles/dev.json +22 -0
- package/templates/profiles/general.json +26 -0
- package/templates/profiles/research.json +57 -0
- package/templates/profiles/writing.json +28 -0
- package/templates/roles/NOTICE.md +26 -0
- package/templates/roles/dev/backend-engineer.md +26 -0
- package/templates/roles/dev/code-reviewer.md +26 -0
- package/templates/roles/dev/frontend-engineer.md +26 -0
- package/templates/roles/dev/security-reviewer.md +26 -0
- package/templates/roles/dev/software-architect.md +26 -0
- package/templates/roles/dev/test-engineer.md +26 -0
- package/templates/roles/research/analyst.md +26 -0
- package/templates/roles/research/fact-checker.md +26 -0
- package/templates/roles/research/research-lead.md +26 -0
- package/templates/roles/research/researcher.md +26 -0
- package/templates/roles/research/statistician.md +26 -0
package/src/hub/budget.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
import type { PeerId } from "./envelope.ts";
|
|
3
|
+
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
3
4
|
|
|
4
5
|
export interface UsageWindow {
|
|
5
6
|
id: "5h" | "week" | "tokens";
|
|
@@ -80,10 +81,12 @@ const EXEMPT = new Set<PeerId>(["local", "user", "hub"]);
|
|
|
80
81
|
export class Budget {
|
|
81
82
|
private readonly db: Database;
|
|
82
83
|
private readonly readings = new Map<PeerId, Map<string, Reading>>();
|
|
83
|
-
private readonly pausing = new
|
|
84
|
+
private readonly pausing = new Map<PeerId, object>();
|
|
84
85
|
private readonly handingOff = new Set<PeerId>();
|
|
85
86
|
/** `ahub budget resume`: the owner overrode a pause; readings over the gate are ignored for that peer until then. */
|
|
86
87
|
private readonly ignoreUntil = new Map<PeerId, number>();
|
|
88
|
+
/** Chosen fallback bounds belong to a window, never to every window of the peer. */
|
|
89
|
+
private readonly resetBounds = new Map<PeerId, Map<string, { at: number; explicit: boolean }>>();
|
|
87
90
|
private closed = false;
|
|
88
91
|
private recoveryHeld = false;
|
|
89
92
|
private deferred = false;
|
|
@@ -96,6 +99,7 @@ export class Budget {
|
|
|
96
99
|
private readonly now: () => number = Date.now,
|
|
97
100
|
) {
|
|
98
101
|
this.db = new Database(dbPath, { create: true });
|
|
102
|
+
this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
99
103
|
this.db.run(`CREATE TABLE IF NOT EXISTS budget_pauses (peer TEXT PRIMARY KEY, since INTEGER NOT NULL, resets_at INTEGER NOT NULL,
|
|
100
104
|
reason TEXT NOT NULL, summary TEXT, handed_off INTEGER NOT NULL DEFAULT 0, moved TEXT NOT NULL DEFAULT '[]')`);
|
|
101
105
|
}
|
|
@@ -153,6 +157,10 @@ export class Budget {
|
|
|
153
157
|
/** `at`: when the numbers were measured, for sources that arrive through a file (a leftover file is not a fresh reading). */
|
|
154
158
|
report(peer: PeerId, windows: UsageWindow[], hard = false, at = this.now()): void {
|
|
155
159
|
if (EXEMPT.has(peer) || this.closed) return;
|
|
160
|
+
// An override ignores soft readings, not a provider that has actually refused another turn.
|
|
161
|
+
if (hard && this.ignoreUntil.delete(peer)) {
|
|
162
|
+
try { this.hooks.notify(`budget: ${peer} provider refused again; budget override ended`); } catch { /* a notice cannot prevent the hard pause */ }
|
|
163
|
+
}
|
|
156
164
|
try { this.hooks.reading?.(peer, windows, hard, at); } catch { /* telemetry never costs a reading */ }
|
|
157
165
|
const mine = this.readings.get(peer) ?? new Map<string, Reading>();
|
|
158
166
|
this.readings.set(peer, mine);
|
|
@@ -172,7 +180,10 @@ export class Budget {
|
|
|
172
180
|
const open = this.record(peer);
|
|
173
181
|
if (!open) return false;
|
|
174
182
|
this.ignoreUntil.set(peer, open.resetsAt);
|
|
183
|
+
const bounds = this.resetBounds.get(peer);
|
|
175
184
|
this.resume(peer, "overridden by the console user");
|
|
185
|
+
// A new refusal may end the override before this same window has reset.
|
|
186
|
+
if (bounds) this.resetBounds.set(peer, bounds);
|
|
176
187
|
return true;
|
|
177
188
|
}
|
|
178
189
|
|
|
@@ -181,8 +192,14 @@ export class Budget {
|
|
|
181
192
|
const fresh = this.fresh(peer);
|
|
182
193
|
const over = fresh.filter((r) => r.used >= this.cfg.gate);
|
|
183
194
|
const open = this.record(peer);
|
|
184
|
-
|
|
195
|
+
// An expired restored record may be waiting for attachment on an unpaused bus. A new hard refusal
|
|
196
|
+
// starts a fresh pause immediately, retaining its list of moved tasks for the eventual resume notice.
|
|
197
|
+
if (open && !(hard && this.now() >= open.resetsAt)) {
|
|
198
|
+
if (hard && over.length) this.hooks.pause(peer); // confirm the persisted pause on the live bus, too
|
|
185
199
|
// A later reading may finally carry the reset time the first one lacked.
|
|
200
|
+
const bounds = this.resetBounds.get(peer) ?? new Map<string, { at: number; explicit: boolean }>();
|
|
201
|
+
this.resetBounds.set(peer, bounds);
|
|
202
|
+
for (const reading of over) if (reading.resetsAt !== undefined) bounds.set(reading.id, { at: reading.resetsAt, explicit: true });
|
|
186
203
|
const known = Math.max(0, ...over.map((r) => r.resetsAt ?? 0));
|
|
187
204
|
if (known && known !== open.resetsAt) {
|
|
188
205
|
// A wait was decided against the old reset (#36). If the new one is past wait_max_min (a week window crossed
|
|
@@ -199,22 +216,36 @@ export class Budget {
|
|
|
199
216
|
if (newer.length && fresh.every((r) => r.used < this.cfg.gate - HYSTERESIS)) this.resume(peer, "usage is back under the gate");
|
|
200
217
|
return;
|
|
201
218
|
}
|
|
202
|
-
if (!over.length || this.pausing.has(peer) || this.now() < (this.ignoreUntil.get(peer) ?? 0)) return;
|
|
203
|
-
|
|
219
|
+
if (!over.length || (this.pausing.has(peer) && !hard) || this.now() < (this.ignoreUntil.get(peer) ?? 0)) return;
|
|
220
|
+
// A hard refusal supersedes a soft evaluation still awaiting its checkpoint.
|
|
221
|
+
const evaluation = {};
|
|
222
|
+
this.pausing.set(peer, evaluation);
|
|
204
223
|
try {
|
|
205
224
|
const worst = over.reduce((a, b) => (b.used > a.used ? b : a));
|
|
206
|
-
const
|
|
225
|
+
const bounds = this.resetBounds.get(peer) ?? new Map<string, { at: number; explicit: boolean }>();
|
|
226
|
+
this.resetBounds.set(peer, bounds);
|
|
227
|
+
const resetsAt = Math.max(...over.map((r) => {
|
|
228
|
+
const fallback = this.now() + (r.windowMins ?? FALLBACK_WINDOW_MINS) * 60_000;
|
|
229
|
+
const prior = bounds.get(r.id);
|
|
230
|
+
// Preserve an earlier future fallback for this window after an override.
|
|
231
|
+
// A provider's explicit reset, including a later one, remains authoritative.
|
|
232
|
+
const retained = prior !== undefined && prior.at > this.now() ? prior : undefined;
|
|
233
|
+
const reset = r.resetsAt ?? (retained ? retained.explicit ? retained.at : Math.min(retained.at, fallback) : fallback);
|
|
234
|
+
bounds.set(r.id, { at: reset, explicit: r.resetsAt !== undefined || retained?.explicit === true });
|
|
235
|
+
return reset;
|
|
236
|
+
}));
|
|
207
237
|
const reason = `${worst.id} window at ${Math.round(worst.used * 100)}% (${worst.source})`;
|
|
208
238
|
// Checkpoint first, pause second: a paused peer receives nothing, and a turn in flight is never cut by the coordinator.
|
|
209
239
|
const summary = hard ? undefined : await this.hooks.requestCheckpoint(peer).catch(() => undefined);
|
|
240
|
+
if (this.pausing.get(peer) !== evaluation) return; // a later hard evaluation owns this pause
|
|
210
241
|
if (this.recoveryHeld) { this.deferred = true; return; }
|
|
211
242
|
if (this.closed) return; // the hub is shutting down: nothing is recorded, the next run sees the reading again
|
|
212
|
-
this.db.query("INSERT OR REPLACE INTO budget_pauses (peer, since, resets_at, reason, summary) VALUES (?, ?, ?, ?, ?)").run(peer, this.now(), resetsAt, reason, summary ?? null);
|
|
243
|
+
this.db.query("INSERT OR REPLACE INTO budget_pauses (peer, since, resets_at, reason, summary, moved) VALUES (?, ?, ?, ?, ?, ?)").run(peer, this.now(), resetsAt, reason, summary ?? null, JSON.stringify(open?.moved ?? []));
|
|
213
244
|
this.hooks.pause(peer);
|
|
214
245
|
this.hooks.notify(`budget: ${peer} paused, ${reason}; resumes ${new Date(resetsAt).toLocaleTimeString()}${summary ? "; checkpoint received" : hard ? "; hard limit, no checkpoint" : "; no checkpoint"}`);
|
|
215
246
|
await this.finishHandoff(peer, summary);
|
|
216
247
|
} finally {
|
|
217
|
-
this.pausing.delete(peer);
|
|
248
|
+
if (this.pausing.get(peer) === evaluation) this.pausing.delete(peer);
|
|
218
249
|
}
|
|
219
250
|
}
|
|
220
251
|
|
|
@@ -285,6 +316,7 @@ export class Budget {
|
|
|
285
316
|
if (!record) return;
|
|
286
317
|
this.db.query("DELETE FROM budget_pauses WHERE peer = ?").run(peer);
|
|
287
318
|
this.readings.get(peer)?.clear(); // the old numbers describe the window that just ended
|
|
319
|
+
this.resetBounds.delete(peer);
|
|
288
320
|
// The notice is queued while the peer is still paused, so it leads the first delivery: the peer learns what moved
|
|
289
321
|
// before it touches anything that was waiting for it.
|
|
290
322
|
this.hooks.resumed(record);
|
package/src/hub/bus.ts
CHANGED
|
@@ -1,12 +1,8 @@
|
|
|
1
1
|
import { appendNote, DIGEST, HUB, keepNotes, MAX_HOP, newEnvelope, noteLine, parseMarker, quoteNotes, replyAudience, type Envelope, type EnvelopeOpts, type PeerId, type PeerState, type Priority } from "./envelope.ts";
|
|
2
|
-
import type { PeerAdapter } from "./peers.ts";
|
|
2
|
+
import type { PeerAdapter, DeliveryReceipt } from "./peers.ts";
|
|
3
3
|
import { DeliveryJournal, type JournalDelivery, type JournalDeliveryState } from "./delivery-journal.ts";
|
|
4
4
|
|
|
5
|
-
export
|
|
6
|
-
id: string;
|
|
7
|
-
state: "accepted" | "completed" | "failed_safe" | "needs_review";
|
|
8
|
-
reason?: string;
|
|
9
|
-
}
|
|
5
|
+
export type { DeliveryReceipt } from "./peers.ts";
|
|
10
6
|
|
|
11
7
|
export type BusEvent =
|
|
12
8
|
| { t: "envelope"; env: Envelope; dropped?: "hop" | "fyi" }
|
|
@@ -14,9 +10,12 @@ export type BusEvent =
|
|
|
14
10
|
| { t: "undeliverable"; env: Envelope; peer: PeerId; reason?: string }
|
|
15
11
|
| { t: "stale"; env: Envelope; peer: PeerId; reason: string }
|
|
16
12
|
| { t: "quiet"; env: Envelope; peers: PeerId[]; reason: string }
|
|
13
|
+
| { t: "off_roster"; env: Envelope; peers: PeerId[] }
|
|
17
14
|
| { t: "state"; peer: PeerId; state: PeerState };
|
|
18
15
|
|
|
19
16
|
export interface BusOptions {
|
|
17
|
+
/** Live project membership; absent preserves the existing audience and delivery behaviour. */
|
|
18
|
+
rostered?: (peer: PeerId) => boolean;
|
|
20
19
|
/** Transient owner lifecycle fence: retain new work without changing console/budget pauses. */
|
|
21
20
|
deliveryHeld?: (peer: PeerId) => boolean;
|
|
22
21
|
/** Observe original envelopes immediately before a transport starts its turn. */
|
|
@@ -159,6 +158,7 @@ export class Bus {
|
|
|
159
158
|
if (!this.queues.has(peer.id)) this.queues.set(peer.id, []);
|
|
160
159
|
peer.onDelivery = (receipt) => this.deliveryReceipt(peer.id, receipt);
|
|
161
160
|
peer.onMessage = (text, opts) => {
|
|
161
|
+
if (!this.isRostered(peer.id)) return `off roster: ${peer.id}`;
|
|
162
162
|
const { priority, body } = parseMarker(text);
|
|
163
163
|
if (!body) return;
|
|
164
164
|
// What the peer was handed differs from what it stands for once a delivery was condensed; both the audience
|
|
@@ -171,15 +171,22 @@ export class Bus {
|
|
|
171
171
|
const audienceOriginals = originals ?? (opts?.to?.includes(DIGEST) && condensed ? last?.originals : undefined);
|
|
172
172
|
const to = opts?.to?.length ? resolveTo(opts.to, audienceOriginals) : undefined;
|
|
173
173
|
let env = newEnvelope(peer.id, body, { ...opts, ...(to ? { to } : {}), priority: opts?.priority ?? capPriority(peer, priority, opts?.inReplyTo, originals) });
|
|
174
|
+
const offRoster = this.offRosterRecipients(env);
|
|
175
|
+
if (offRoster.length && offRoster.length === new Set(env.to?.filter(id => id !== env.from)).size) {
|
|
176
|
+
const reason = `off roster: ${offRoster.join(", ")}`;
|
|
177
|
+
this.note(peer.id, noteLine(HUB, "decision", `your message was not delivered: ${reason}`));
|
|
178
|
+
return reason; // no publication and no limiter spend for an entirely refused audience
|
|
179
|
+
}
|
|
180
|
+
const admittedEnv = offRoster.length ? { ...env, to: env.to!.filter(id => !offRoster.includes(id)) } : env;
|
|
174
181
|
// Limits count what is sent: the envelope as built (a reply goes to its parent's sender, `digest` is resolved,
|
|
175
182
|
// the priority is capped), never the raw `to`.
|
|
176
183
|
const parent = opts?.inReplyTo?.id;
|
|
177
184
|
// Held back from every recipient (issue #107): nothing goes out, so nothing counts against the sender's limits.
|
|
178
185
|
const hushed = this.hushed(env);
|
|
179
186
|
const heldBack = hushed.length > 0 && hushed.length === this.audience(env).length;
|
|
180
|
-
let refused = heldBack ? undefined : this.opts.admit?.(
|
|
187
|
+
let refused = heldBack ? undefined : this.opts.admit?.(admittedEnv, parent);
|
|
181
188
|
// A turn answer has no caller to refuse: over its important budget it goes out as status, not at all.
|
|
182
|
-
if (refused && env.priority === "important" && !this.opts.admit?.({ ...
|
|
189
|
+
if (refused && env.priority === "important" && !this.opts.admit?.({ ...admittedEnv, priority: "status" }, parent)) {
|
|
183
190
|
// It went out, so the advice on how to send it does not apply.
|
|
184
191
|
this.note(peer.id, noteLine(HUB, "decision", `your [IMPORTANT] message went out as status: ${refused.replace(/[;:] (retry after \d+ s, or )?send it without \[IMPORTANT\]$/, "")}`));
|
|
185
192
|
env = { ...env, priority: "status" };
|
|
@@ -190,12 +197,13 @@ export class Bus {
|
|
|
190
197
|
return refused;
|
|
191
198
|
}
|
|
192
199
|
this.publish(env);
|
|
200
|
+
if (offRoster.length) this.note(peer.id, noteLine(HUB, "decision", `your message is retained off roster for ${offRoster.join(", ")}; delivered to the other recipients`));
|
|
193
201
|
// The sender's result (issue #107): a turn answer has no caller, so it hears it on its next delivery. It counts as
|
|
194
202
|
// not sent only when nobody got it.
|
|
195
203
|
if (hushed.length) this.note(peer.id, noteLine(HUB, "decision", `your message was not delivered to ${hushed.map((h) => h.peer).join(", ")}: ${hushed[0]!.reason}`));
|
|
196
204
|
return heldBack ? hushed[0]!.reason : undefined;
|
|
197
205
|
};
|
|
198
|
-
peer.onFailed = (envs) => {
|
|
206
|
+
peer.onFailed = (envs, failure) => {
|
|
199
207
|
// The adapter got the condensed list; what has to come back is what that list replaced.
|
|
200
208
|
const last = this.lastDelivery.get(peer.id);
|
|
201
209
|
const same = !!last && envs.length === last.out.length && envs.every((e, i) => e.id === last.out[i]!.id);
|
|
@@ -207,7 +215,7 @@ export class Bus {
|
|
|
207
215
|
this.deliveryReceipt(peer.id, { id: deliveryId, state: "needs_review", reason: "adapter reported delivery failure without safe-failure evidence" });
|
|
208
216
|
}
|
|
209
217
|
}
|
|
210
|
-
} else this.failed(peer.id, same ? last.originals : envs);
|
|
218
|
+
} else this.failed(peer.id, same ? last.originals : envs, undefined, failure?.quota === true);
|
|
211
219
|
};
|
|
212
220
|
peer.onState = () => {
|
|
213
221
|
if (this.closed) return;
|
|
@@ -423,7 +431,7 @@ export class Bus {
|
|
|
423
431
|
this.journal.transition(id, state, receipt.reason);
|
|
424
432
|
if (before?.state === "dispatching" && (state === "accepted" || state === "completed")) this.delivered(peer, before.originals);
|
|
425
433
|
if (state === "failed") {
|
|
426
|
-
if (before) this.failed(peer, before.originals, before.originals.some((e) => e.private) ? "private delivery failed" : receipt.reason);
|
|
434
|
+
if (before) this.failed(peer, before.originals, before.originals.some((e) => e.private) ? "private delivery failed" : receipt.reason, receipt.quota === true && before.state === "dispatching");
|
|
427
435
|
}
|
|
428
436
|
if (state === "completed") { this.failureStreak.delete(peer); this.lastFailure.delete(peer); }
|
|
429
437
|
if (state === "completed" || state === "failed" || state === "needs_review") this.activeDeliveries.delete(id);
|
|
@@ -470,6 +478,17 @@ export class Bus {
|
|
|
470
478
|
return (this.queues.get(id) ?? []).map((e) => e.id);
|
|
471
479
|
}
|
|
472
480
|
|
|
481
|
+
queuedBacklogIds(id: PeerId): Set<number> {
|
|
482
|
+
const ids = new Set<number>();
|
|
483
|
+
const retained = [...(this.queues.get(id) ?? []), ...(this.journal?.unresolved(id).filter(row => row.state !== "queued").flatMap(row => row.originals) ?? [])];
|
|
484
|
+
for (const env of retained) {
|
|
485
|
+
const backlog = env.refs?.backlog;
|
|
486
|
+
if (env.from !== HUB || env.kind !== "task" || !Array.isArray(backlog) || backlog.length > 64) continue;
|
|
487
|
+
for (const task of backlog) if (Number.isSafeInteger(task) && task > 0) ids.add(task);
|
|
488
|
+
}
|
|
489
|
+
return ids;
|
|
490
|
+
}
|
|
491
|
+
|
|
473
492
|
attemptState(): Record<string, number> {
|
|
474
493
|
return Object.fromEntries(this.attempts);
|
|
475
494
|
}
|
|
@@ -545,12 +564,12 @@ export class Bus {
|
|
|
545
564
|
|
|
546
565
|
const hushed = this.hushed(env);
|
|
547
566
|
if (hushed.length) this.emit({ t: "quiet", env, peers: hushed.map((h) => h.peer), reason: hushed[0]!.reason });
|
|
548
|
-
const targets = this.audience(env).filter((id) => !hushed.some((h) => h.peer === id));
|
|
567
|
+
const targets = this.audience(env).filter((id) => !hushed.some((h) => h.peer === id) || !this.isRostered(id));
|
|
549
568
|
this.suppressDrain = !!this.journal;
|
|
550
569
|
try { for (const id of targets) {
|
|
551
570
|
const peer = this.peers.get(id);
|
|
552
571
|
if (!peer) { this.enqueue(id, env); continue; }
|
|
553
|
-
if (!this.journal && !this.
|
|
572
|
+
if (!this.journal && !this.held(id) && env.priority === "important" && peer.steer && this.stateOf(id) === "busy") {
|
|
554
573
|
// Not queued while the steer is in flight, or an idle transition would deliver it a second time.
|
|
555
574
|
this.steering++;
|
|
556
575
|
const deliveryId = crypto.randomUUID();
|
|
@@ -570,23 +589,39 @@ export class Bus {
|
|
|
570
589
|
this.onQueues?.();
|
|
571
590
|
for (const id of targets) {
|
|
572
591
|
const peer = this.peers.get(id);
|
|
573
|
-
if (!this.
|
|
592
|
+
if (!this.held(id) && env.priority === "important" && peer?.steer && this.stateOf(id) === "busy") void this.steerQueued(id, env);
|
|
574
593
|
else void this.drain(id);
|
|
575
594
|
}
|
|
576
595
|
}
|
|
596
|
+
const offRoster = targets.filter(id => !this.isRostered(id));
|
|
597
|
+
if (offRoster.length) this.emit({ t: "off_roster", env, peers: offRoster });
|
|
577
598
|
return targets;
|
|
578
599
|
}
|
|
579
600
|
|
|
601
|
+
isRostered(peer: PeerId): boolean { return this.opts.rostered?.(peer) ?? true; }
|
|
602
|
+
|
|
603
|
+
/** Explicit off-roster recipients, including unattached retained targets; broadcasts have none. */
|
|
604
|
+
offRosterRecipients(env: Envelope): PeerId[] {
|
|
605
|
+
return [...new Set(env.to ?? [])].filter(peer => peer !== env.from && !this.isRostered(peer));
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** Membership changed externally: reconsider delivery, keeping every independent hold intact. */
|
|
609
|
+
rosterChanged(peer: PeerId): void {
|
|
610
|
+
if (this.closed) return;
|
|
611
|
+
this.onQueues?.();
|
|
612
|
+
void this.drain(peer);
|
|
613
|
+
}
|
|
614
|
+
|
|
580
615
|
/** Who an envelope is queued for: its `to`, or every peer but its sender. */
|
|
581
616
|
audience(env: Envelope): PeerId[] {
|
|
582
617
|
const known = new Set([...this.peers.keys(), ...this.queues.keys(), ...(this.journal?.list().map((d) => d.peer) ?? [])]);
|
|
583
|
-
return (env.to ?? [...this.peers.keys()]).filter((id) => id !== env.from && known.has(id));
|
|
618
|
+
return (env.to ?? [...this.peers.keys()]).filter((id) => id !== env.from && (known.has(id) || !this.isRostered(id)) && (env.to !== undefined || this.isRostered(id)));
|
|
584
619
|
}
|
|
585
620
|
|
|
586
621
|
/** The recipients of `env` that the silence policy holds it back from, each with why (issue #107). */
|
|
587
622
|
hushed(env: Envelope): { peer: PeerId; reason: string }[] {
|
|
588
623
|
if (!this.opts.silence || env.hop > MAX_HOP || env.priority === "fyi") return [];
|
|
589
|
-
return this.audience(env).flatMap((peer) => {
|
|
624
|
+
return this.audience(env).filter(peer => this.isRostered(peer)).flatMap((peer) => {
|
|
590
625
|
const reason = this.opts.silence!(env, peer);
|
|
591
626
|
return reason ? [{ peer, reason }] : [];
|
|
592
627
|
});
|
|
@@ -732,9 +767,9 @@ export class Bus {
|
|
|
732
767
|
finally { this.steering--; this.onQueues?.(); }
|
|
733
768
|
}
|
|
734
769
|
|
|
735
|
-
/** Holds on a peer's queue, for a drain and a pull alike: recovery, an uncertain delivery, a pause (console, budget, conductor). */
|
|
770
|
+
/** Holds on a peer's queue, for a drain and a pull alike: off roster, recovery, an uncertain delivery, a pause (console, budget, conductor). */
|
|
736
771
|
private held(id: PeerId): boolean {
|
|
737
|
-
return this.recoveryHeld || this.recoveryHeldPeers.has(id) || this.paused.has(id) || this.opts.deliveryHeld?.(id) === true;
|
|
772
|
+
return !this.isRostered(id) || this.recoveryHeld || this.recoveryHeldPeers.has(id) || this.paused.has(id) || this.opts.deliveryHeld?.(id) === true;
|
|
738
773
|
}
|
|
739
774
|
|
|
740
775
|
/** What a peer was just handed stays resolvable for `reply_to`: moved to the newest end, the stored envelope kept. */
|
|
@@ -906,10 +941,12 @@ export class Bus {
|
|
|
906
941
|
return [...this.activeDeliveries.values()].includes(peer) || this.draining.has(peer);
|
|
907
942
|
}
|
|
908
943
|
|
|
909
|
-
private failed(id: PeerId, envs: Envelope[], reason = "delivery failed without an error detail"): void {
|
|
944
|
+
private failed(id: PeerId, envs: Envelope[], reason = "delivery failed without an error detail", quota = false): void {
|
|
910
945
|
if (!this.journal) this.deliveryFailed(id);
|
|
946
|
+
// A quota refusal ran no work. The budget pause fences retries until reset; it is not a poison attempt.
|
|
947
|
+
const quotaPaused = quota && this.paused.has(id);
|
|
911
948
|
this.lastFailure.set(id, reason.replace(/\s+/g, " ").slice(0, 300));
|
|
912
|
-
if (envs.some((env) => !(env.from === HUB && env.kind === "presence") && (this.attempts.get(`${id}:${env.id}`) ?? 0) + 1 >= MAX_ATTEMPTS)) {
|
|
949
|
+
if (!quotaPaused && envs.some((env) => !(env.from === HUB && env.kind === "presence") && (this.attempts.get(`${id}:${env.id}`) ?? 0) + 1 >= MAX_ATTEMPTS)) {
|
|
913
950
|
this.failureStreak.set(id, (this.failureStreak.get(id) ?? 0) + 1);
|
|
914
951
|
}
|
|
915
952
|
const keep = envs.filter((env) => {
|
|
@@ -918,6 +955,7 @@ export class Bus {
|
|
|
918
955
|
this.restorePreface(id, env); // rides again on the next delivery, without counting as a failed envelope
|
|
919
956
|
return false;
|
|
920
957
|
}
|
|
958
|
+
if (quotaPaused) return true;
|
|
921
959
|
const key = `${id}:${env.id}`;
|
|
922
960
|
const n = (this.attempts.get(key) ?? 0) + 1;
|
|
923
961
|
if (n < MAX_ATTEMPTS) return this.attempts.set(key, n);
|
package/src/hub/child-process.ts
CHANGED
|
@@ -11,6 +11,17 @@ export function childEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.Proces
|
|
|
11
11
|
return env;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* #269: the environment of a terminal the hub opens for a person: no agent marker and no hub-run flag, so `ahub`
|
|
16
|
+
* typed or started there has the console's identity, whatever started the hub.
|
|
17
|
+
*/
|
|
18
|
+
export function personEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
19
|
+
const env = childEnv(source);
|
|
20
|
+
// The Orca terminal the hub itself was started in is not this new terminal: a launch there must not record it as its own.
|
|
21
|
+
for (const name of ["CLAUDECODE", "CLAUDE_CODE_SESSION_ID", "CODEX_THREAD_ID", "AGENTHUB_CHANNEL", "AGENTHUB_PEER_ID", "AGENTHUB_UNATTENDED", "ORCA_TERMINAL_HANDLE", "ORCA_WORKTREE_ID"]) delete env[name];
|
|
22
|
+
return env;
|
|
23
|
+
}
|
|
24
|
+
|
|
14
25
|
/** Every native child owns its marker; caller vendor markers must not leak across peers (#193). */
|
|
15
26
|
export function peerChildEnv(peer: string, source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
16
27
|
const env = childEnv(source);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Bounded Markdown [@key] and BibTeX entry-key example check, no fetching or model verdict. */
|
|
2
|
+
export function citationKeys(markdown: string, bibliography: string): { cited: string[]; missing: string[] } {
|
|
3
|
+
if (Buffer.byteLength(markdown) > 256 * 1024 || Buffer.byteLength(bibliography) > 4 * 1024 * 1024) throw new Error("citation inputs exceed example check limits");
|
|
4
|
+
const known = new Set([...bibliography.matchAll(/@[a-zA-Z]+\s*\{\s*([^,\s{}]+)\s*,/g)].map(m => m[1]!));
|
|
5
|
+
const cited = [...new Set([...markdown.matchAll(/\[@[A-Za-z0-9_:./-]+(?:\s*;\s*@[A-Za-z0-9_:./-]+)*\]/g)].flatMap(m => [...m[0].matchAll(/@([A-Za-z0-9_:./-]+)/g)].map(k => k[1]!)))].sort();
|
|
6
|
+
return { cited, missing: cited.filter(key => !known.has(key)) };
|
|
7
|
+
}
|
package/src/hub/conductor.ts
CHANGED
|
@@ -2,11 +2,13 @@ import { Database } from "bun:sqlite";
|
|
|
2
2
|
import type { Task } from "./board.ts";
|
|
3
3
|
import { CONDUCTOR_TOOL_NAMES } from "./hub-tools.ts";
|
|
4
4
|
import { HUB, USER } from "./envelope.ts";
|
|
5
|
-
import { OWNERSHIP_EVENTS } from "./tasks.ts";
|
|
5
|
+
import { OWNERSHIP_EVENTS, type TaskAssignmentRole } from "./tasks.ts";
|
|
6
6
|
import { PreEffectToolRefusal } from "./tool-refusal.ts";
|
|
7
|
+
import { isStartablePeer, type StartablePeer, type StartMode } from "./start-mode.ts";
|
|
7
8
|
import type { HubEvent } from "./events.ts";
|
|
8
9
|
import type { SupervisionFeed } from "./supervision.ts";
|
|
9
10
|
import type { Budget } from "./budget.ts";
|
|
11
|
+
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
10
12
|
|
|
11
13
|
/** The production ProgressObserver sink forwards structured verdicts, never its observations/reasoning. */
|
|
12
14
|
export function conductorProgressSink(record: (event: HubEvent) => void, feed: Pick<SupervisionFeed, "milestone">): (event: HubEvent) => void {
|
|
@@ -65,6 +67,7 @@ export class ConductorHolds {
|
|
|
65
67
|
private readonly db: Database;
|
|
66
68
|
constructor(dbPath: string, private readonly now: () => number = Date.now) {
|
|
67
69
|
this.db = new Database(dbPath, { create: true });
|
|
70
|
+
this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
68
71
|
this.db.run("CREATE TABLE IF NOT EXISTS conductor_holds (peer TEXT PRIMARY KEY, actor TEXT NOT NULL, since INTEGER NOT NULL)");
|
|
69
72
|
this.db.run("CREATE TABLE IF NOT EXISTS supervision_rounds (peer TEXT PRIMARY KEY, signature TEXT NOT NULL)");
|
|
70
73
|
}
|
|
@@ -100,13 +103,18 @@ export class ConductorHolds {
|
|
|
100
103
|
close(): void { this.db.close(); }
|
|
101
104
|
}
|
|
102
105
|
|
|
103
|
-
export type ConductorStart = { peer:
|
|
104
|
-
/**
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
106
|
+
export type ConductorStart = { peer: StartablePeer; mode: "headless" } | { peer: "claude" | "codex" | "pi"; mode: "tui"; command: string };
|
|
107
|
+
/**
|
|
108
|
+
* What a conductor's start of `peer` is (#269): the peer's own start mode, which a person sets. A `mode` argument may
|
|
109
|
+
* only repeat it. Preview commands come from the same launch planner the CLI uses, never caller-supplied shell text.
|
|
110
|
+
*/
|
|
111
|
+
export function conductorStart(peer: unknown, mode: unknown, preview: (peer: "claude" | "codex" | "pi") => string, preEffect: boolean, startMode: (peer: StartablePeer) => StartMode): ConductorStart {
|
|
112
|
+
const refuse = (text: string) => new (preEffect ? PreEffectToolRefusal : Error)(text);
|
|
113
|
+
if (mode !== undefined && mode !== "headless" && mode !== "tui") throw refuse("mode must be headless or tui");
|
|
114
|
+
if (!isStartablePeer(peer)) throw refuse("only claude, codex, kimi, pi and local, plus opt-in qwen/jcode, may be started by the conductor");
|
|
115
|
+
const own = startMode(peer);
|
|
116
|
+
if (mode !== undefined && mode !== own) throw refuse(`${peer} starts ${own} here; a person sets a start mode (ahub settings), the conductor cannot choose one`);
|
|
117
|
+
return own === "tui" ? { peer: peer as "claude" | "codex" | "pi", mode: "tui", command: preview(peer as "claude" | "codex" | "pi") } : { peer, mode: "headless" };
|
|
110
118
|
}
|
|
111
119
|
|
|
112
120
|
const numberOrNull = (v: unknown): number | null => typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null;
|
|
@@ -148,10 +156,13 @@ export interface ConductorHooks {
|
|
|
148
156
|
task(id: number): Task | undefined;
|
|
149
157
|
publicView(task: Task): Record<string, unknown>;
|
|
150
158
|
/** These callbacks must call Tasks, never Board.update. */
|
|
151
|
-
assign(actor: string, id: number, peer: string): Promise<unknown>;
|
|
159
|
+
assign(actor: string, id: number, peer: string, role?: TaskAssignmentRole): Promise<unknown>;
|
|
152
160
|
escalate(actor: string, id: number): Promise<unknown>;
|
|
153
161
|
preview(peer: "claude" | "codex" | "pi"): string;
|
|
154
|
-
start
|
|
162
|
+
/** The mode a start of this peer takes: a person's setting. */
|
|
163
|
+
startMode(peer: StartablePeer): StartMode;
|
|
164
|
+
/** Starts the peer in that mode: headless at once, or its fixed command in a terminal; throws with the command to run by hand when no terminal can be opened. */
|
|
165
|
+
start(peer: StartablePeer): Promise<unknown>;
|
|
155
166
|
known(peer: string): boolean;
|
|
156
167
|
pause(peer: string): void;
|
|
157
168
|
validateRelease?(peer: string): Promise<void>;
|
|
@@ -171,18 +182,20 @@ export class Conductor {
|
|
|
171
182
|
constructor(private readonly holds: ConductorHolds, private readonly hooks: ConductorHooks) {}
|
|
172
183
|
async execute(actor: string, tool: string, args: Record<string, unknown>): Promise<unknown> {
|
|
173
184
|
if (!CONDUCTOR_TOOL_NAMES.has(tool)) throw new PreEffectToolRefusal("unknown conductor tool");
|
|
185
|
+
const role = args.role ?? "owner";
|
|
186
|
+
if (tool === "hub_task_assign" && role !== "owner" && role !== "reviewer") throw new PreEffectToolRefusal("role must be owner or reviewer");
|
|
174
187
|
if ((tool === "hub_task_show" || tool === "hub_task_assign") && conductorPeer(this.hooks.roles()) !== actor) {
|
|
175
188
|
const id = taskId(args.id);
|
|
176
189
|
const task = id === undefined ? undefined : this.hooks.task(id);
|
|
177
190
|
// A task's current owner and reviewer read its public view without the role (#208); nobody else does.
|
|
178
|
-
if (task && tool === "hub_task_show" && (task.owner === actor || task.reviewer === actor)) return publicConductorTask(task, this.hooks.publicView);
|
|
191
|
+
if (task && task.state !== "backlog" && tool === "hub_task_show" && (task.owner === actor || task.reviewer === actor)) return publicConductorTask(task, this.hooks.publicView);
|
|
179
192
|
// Its proposer (the first history entry) redirects it while nobody ever accepted it (#207: a decline or a release puts
|
|
180
193
|
// worked tasks back in proposed); work that waits gets a reserved owner. Never over the person: a console assign or
|
|
181
194
|
// reservation stands until the conductor or the console moves it, also once the hub carried it out (the hub's own
|
|
182
195
|
// moves are not decisions).
|
|
183
|
-
const moved = task?.history.findLast((h) => h.by !== HUB && (OWNERSHIP_EVENTS.has(h.event) || h.event === "reserved"));
|
|
196
|
+
const moved = task?.history.findLast((h) => h.by !== HUB && (OWNERSHIP_EVENTS.has(h.event) || h.event === "reserved" || h.planning?.fields.includes("reserved")));
|
|
184
197
|
const accepted = task?.history.some((h) => h.event === "accepted");
|
|
185
|
-
if (task && tool === "hub_task_assign" && task.history[0]?.by === actor && task.state === "proposed" && !accepted && moved?.by !== USER) {
|
|
198
|
+
if (task && tool === "hub_task_assign" && role === "owner" && task.history[0]?.by === actor && task.state === "proposed" && !accepted && moved?.by !== USER) {
|
|
186
199
|
const peer = peerId(args.peer, true);
|
|
187
200
|
if (peer !== actor) requireAssign(actor, this.hooks.capabilities(), true);
|
|
188
201
|
await this.hooks.assign(actor, task.id, peer);
|
|
@@ -201,19 +214,23 @@ export class Conductor {
|
|
|
201
214
|
if (id === undefined) throw new PreEffectToolRefusal("id must be a positive integer");
|
|
202
215
|
const task = this.hooks.task(id);
|
|
203
216
|
if (!task) throw new PreEffectToolRefusal(`no task #${args.id}`);
|
|
217
|
+
if (task.state === "backlog") throw new PreEffectToolRefusal("backlog is read through planner tools, not conductor task tools");
|
|
204
218
|
if (tool === "hub_task_show") { const result = publicConductorTask(task, this.hooks.publicView); emit({ task: task.id }); return result; }
|
|
205
219
|
if (tool === "hub_task_assign") {
|
|
206
220
|
const peer = peerId(args.peer, true);
|
|
207
|
-
await this.hooks.assign(actor, task.id, peer
|
|
221
|
+
if (role === "reviewer") await this.hooks.assign(actor, task.id, peer, role);
|
|
222
|
+
else await this.hooks.assign(actor, task.id, peer);
|
|
223
|
+
emit({ task: task.id, peer });
|
|
208
224
|
} else { await this.hooks.escalate(actor, task.id); emit({ task: task.id }); }
|
|
209
225
|
// Task callbacks may return raw objects; always re-read and apply the public view.
|
|
210
226
|
const updated = this.hooks.task(task.id);
|
|
211
227
|
return updated ? publicConductorTask(updated, this.hooks.publicView) : { id: task.id };
|
|
212
228
|
}
|
|
213
229
|
if (tool === "hub_peer_start") {
|
|
214
|
-
const start = conductorStart(args.peer, args.mode, this.hooks.preview, true);
|
|
215
|
-
|
|
216
|
-
|
|
230
|
+
const start = conductorStart(args.peer, args.mode, this.hooks.preview, true, this.hooks.startMode);
|
|
231
|
+
const result = await this.hooks.start(start.peer);
|
|
232
|
+
const opened = result && typeof result === "object" && typeof (result as { opened?: unknown }).opened === "string" ? { opened: (result as { opened: string }).opened } : {};
|
|
233
|
+
emit({ peer: start.peer }); return { ...start, ...opened };
|
|
217
234
|
}
|
|
218
235
|
const peer = peerId(args.peer, true);
|
|
219
236
|
if (!this.hooks.known(peer)) throw new PreEffectToolRefusal(`unknown peer: ${peer}`);
|
package/src/hub/config-trust.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { spawnSync } from "node:child_process";
|
|
2
1
|
import { statSync } from "node:fs";
|
|
3
2
|
import { join } from "node:path";
|
|
4
3
|
import { childEnv } from "./child-process.ts";
|
|
4
|
+
import { hubGitSync } from "./git.ts";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Fields of the project config that choose what the hub runs, which files it sends as credentials, where task text
|
|
@@ -26,7 +26,10 @@ export const MACHINE_LOCAL = [
|
|
|
26
26
|
"local.read_allow",
|
|
27
27
|
"local.bash_network",
|
|
28
28
|
"local.network_allow", // the hosts the egress proxy opens (#65)
|
|
29
|
+
"terminal.open", // the command a hub-made TUI start runs to open a terminal (#269)
|
|
29
30
|
] as const;
|
|
31
|
+
/** Legacy exported field list stays compatible; new policies use the complete list below. */
|
|
32
|
+
export const ALL_MACHINE_LOCAL = [...MACHINE_LOCAL, "protect_instruction_files", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "text_projection"] as const;
|
|
30
33
|
|
|
31
34
|
/**
|
|
32
35
|
* Why `.agenthub/<name>` must not set machine-local fields, or undefined when git confirms nobody committed it.
|
|
@@ -35,17 +38,28 @@ export const MACHINE_LOCAL = [
|
|
|
35
38
|
* spellings git keeps apart (letter case, and folds such as "ſ" for "s").
|
|
36
39
|
*/
|
|
37
40
|
export function configRefusal(cwd: string, name: string): string | undefined {
|
|
38
|
-
const
|
|
39
|
-
|
|
41
|
+
const tracked = configTracked(cwd, name);
|
|
42
|
+
return tracked === undefined ? `git could not confirm that .agenthub/${name} is untracked` : tracked ? `.agenthub/${name} is committed to git` : undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Whether git tracks `.agenthub/<name>` (or `.agenthub` itself); undefined when git cannot say. */
|
|
46
|
+
export function configTracked(cwd: string, name: string): boolean | undefined {
|
|
47
|
+
const r = hubGitSync(["-C", cwd, "ls-files", "-s", "-z", "--", ":(icase).agenthub"], { encoding: "utf8", env: childEnv(process.env) });
|
|
48
|
+
if (r.status !== 0) return undefined;
|
|
40
49
|
let opened: { dev: number; ino: number } | undefined;
|
|
41
50
|
try {
|
|
42
51
|
opened = statSync(join(cwd, ".agenthub", name));
|
|
43
52
|
} catch {
|
|
44
53
|
// not there: only the directory entry can still matter
|
|
45
54
|
}
|
|
46
|
-
|
|
55
|
+
return r.stdout.split("\0").filter(Boolean).some((row) => {
|
|
47
56
|
const path = row.slice(row.indexOf("\t") + 1);
|
|
48
57
|
if (path.toLowerCase() === ".agenthub") return true;
|
|
58
|
+
// A file that is not there has no identity to match: the index entry of this exact name is the one a write would
|
|
59
|
+
// change. Another spelling is another file where the disk keeps them apart.
|
|
60
|
+
// ponytail: on a disk that folds names, an entry tracked under another spelling and deleted from the work tree is
|
|
61
|
+
// not seen here; probe the disk's folding and compare folded names if that case ever matters.
|
|
62
|
+
if (!opened) return path === `.agenthub/${name}`;
|
|
49
63
|
try {
|
|
50
64
|
const s = statSync(join(cwd, path));
|
|
51
65
|
return !!opened && s.dev === opened.dev && s.ino === opened.ino;
|
|
@@ -53,7 +67,6 @@ export function configRefusal(cwd: string, name: string): string | undefined {
|
|
|
53
67
|
return false;
|
|
54
68
|
}
|
|
55
69
|
});
|
|
56
|
-
return tracked ? `.agenthub/${name} is committed to git` : undefined;
|
|
57
70
|
}
|
|
58
71
|
|
|
59
72
|
const valueAt = (o: unknown, path: string): unknown => path.split(".").reduce<unknown>((v, k) => (v && typeof v === "object" ? (v as Record<string, unknown>)[k] : undefined), o);
|
|
@@ -73,8 +86,8 @@ const drop = (file: Record<string, unknown>, path: string) => {
|
|
|
73
86
|
* once resolved to the project root). A committed copy of the template sets them to defaults and asks git nothing.
|
|
74
87
|
*/
|
|
75
88
|
export function stripUntrusted(file: Record<string, unknown>, defaults: object, cwd: string, name: string): string | undefined {
|
|
76
|
-
for (const p of
|
|
77
|
-
const set =
|
|
89
|
+
for (const p of ALL_MACHINE_LOCAL) if (valueAt(file, p) !== undefined && !chooses(valueAt(file, p))) drop(file, p);
|
|
90
|
+
const set = ALL_MACHINE_LOCAL.filter((p) => {
|
|
78
91
|
const v = valueAt(file, p);
|
|
79
92
|
return v !== undefined && JSON.stringify(v) !== JSON.stringify(valueAt(defaults, p));
|
|
80
93
|
});
|
package/src/hub/conflicts.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Database } from "bun:sqlite";
|
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import type { Task } from "./board.ts";
|
|
4
4
|
import { samePlace } from "./tasks.ts";
|
|
5
|
+
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
5
6
|
|
|
6
7
|
/** A file an owner's open task changed, as the turn snapshots saw it (issue #32). */
|
|
7
8
|
export interface Touch {
|
|
@@ -39,6 +40,7 @@ export function pathWarnings(dbFile: string, peer: string, file: { project: stri
|
|
|
39
40
|
if (!existsSync(dbFile)) return [];
|
|
40
41
|
const db = new Database(dbFile, { readonly: true });
|
|
41
42
|
try {
|
|
43
|
+
db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
42
44
|
const rows = db.query("SELECT * FROM tasks WHERE owner IS NOT NULL AND owner != ? AND state IN ('proposed', 'in_progress', 'in_review', 'changes_requested') ORDER BY id").all(peer) as Record<string, string | number | null>[];
|
|
43
45
|
let touches: Touch[] = [];
|
|
44
46
|
try {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Native active-context occupancy, separate from accumulated billable usage (#185). */
|
|
2
2
|
export interface ContextReading {
|
|
3
|
-
source: "claude_statusline" | "codex_token_usage";
|
|
3
|
+
source: "claude_statusline" | "codex_token_usage" | "acp_usage_update";
|
|
4
4
|
sessionId: string;
|
|
5
5
|
measuredAt: number;
|
|
6
6
|
tokens: number | null;
|
|
@@ -37,6 +37,13 @@ export function codexContext(value: unknown, sessionId: string, measuredAt: numb
|
|
|
37
37
|
const tokens = nonnegative(last.totalTokens) ? last.totalTokens : null;
|
|
38
38
|
return { source: "codex_token_usage", sessionId, measuredAt, tokens, window, used: tokens !== null && window !== null ? Math.min(1, tokens / window) : null };
|
|
39
39
|
}
|
|
40
|
+
/** An ACP `usage_update` of the `{ used, size }` shape (Kimi 2.x): context occupancy, not consumption (#167, #285). */
|
|
41
|
+
export function acpContext(usage: { used: number; size: number }, sessionId: string, measuredAt: number): ContextReading {
|
|
42
|
+
const window = positive(usage.size) ? usage.size : null;
|
|
43
|
+
const tokens = nonnegative(usage.used) ? usage.used : null;
|
|
44
|
+
// If a report ever shows used > size, the fraction clamps so a bar renders; tokens keeps the reported number.
|
|
45
|
+
return { source: "acp_usage_update", sessionId, measuredAt, tokens, window, used: tokens !== null && window !== null ? Math.min(1, tokens / window) : null };
|
|
46
|
+
}
|
|
40
47
|
export const unknownContext = (): ContextView => ({ source: null, measuredAt: null, tokens: null, window: null, used: null, freshness: "unknown" });
|
|
41
48
|
/** Invalid/stale readings hide the value but never rearm a threshold already crossed. */
|
|
42
49
|
export class ContextWindows {
|
package/src/hub/crash.ts
CHANGED
|
@@ -47,6 +47,8 @@ export function removeSessions(stateDir: string, instanceId?: string): void {
|
|
|
47
47
|
if (instanceId === undefined || readSessions(stateDir)?.instanceId === instanceId) rmSync(fileOf(stateDir), { force: true });
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
export const qwenSessionId = (value: unknown): value is string => typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/.test(value);
|
|
51
|
+
|
|
50
52
|
const str = (v: unknown) => (typeof v === "string" && v ? v : undefined);
|
|
51
53
|
|
|
52
54
|
/**
|
|
@@ -62,6 +64,13 @@ export function crashPlan(records: SessionRecord[]): { peer: string; resume?: Re
|
|
|
62
64
|
return { peer, how: "claude: the Claude Code plugin reconnects by itself while that session is still open" };
|
|
63
65
|
case "codex":
|
|
64
66
|
return { peer, how: `codex: its app-server died with the hub; run ahub codex again${str(meta.threadId) ? ` (its conversation was thread ${meta.threadId})` : ""}` };
|
|
67
|
+
case "jcode":
|
|
68
|
+
return { peer, how: "jcode: native 0.94.0 load does not prove the restored id; reopen the original conversation with its native client, no automatic fresh replacement" };
|
|
69
|
+
case "qwen": {
|
|
70
|
+
const sessionId = meta.sessionId;
|
|
71
|
+
if (!qwenSessionId(sessionId)) return { peer, how: "qwen: saved ACP session identity unavailable; start it deliberately with ahub qwen" };
|
|
72
|
+
return { peer, resume: { sessionId, ...(model ? { model } : {}) }, how: "qwen: the recorded conversation can be loaded again (ACP session/load)" };
|
|
73
|
+
}
|
|
65
74
|
case "kimi": {
|
|
66
75
|
const sessionId = str(meta.sessionId);
|
|
67
76
|
if (!sessionId) return { peer, how: "kimi: no session id was recorded; start it again with ahub kimi" };
|
|
@@ -72,7 +81,8 @@ export function crashPlan(records: SessionRecord[]): { peer: string; resume?: Re
|
|
|
72
81
|
const fresh: Record<string, string> = {};
|
|
73
82
|
for (const k of ["backend", "model"] as const) if (str(launch[k])) fresh[k] = str(launch[k])!;
|
|
74
83
|
const sessionFile = str(meta.sessionFile) ?? str(launch.sessionFile);
|
|
75
|
-
|
|
84
|
+
// #269: a terminal Pi stays marked as one without a session file too, so nothing headless is started in its place.
|
|
85
|
+
if (!sessionFile) return { peer, fresh, ...(str(launch.mode) === "tui" ? { tui: true } : {}), how: "pi: no session file was recorded; start it again with ahub pi" };
|
|
76
86
|
// A terminal Pi is run by the CLI that launched it, not by the hub: nothing here could start it again.
|
|
77
87
|
if (str(launch.mode) === "tui") return { peer, fresh, tui: true, how: `pi: it ran in a terminal; start it again with ahub pi --mode tui --session-file ${sessionFile}` };
|
|
78
88
|
const args: Record<string, string> = { sessionFile };
|