@staix/agent-hub 0.29.0 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/README.md +15 -7
- package/docs/agent-notes/README.md +13 -0
- package/docs/agent-notes/adapters.md +7 -3
- package/docs/agent-notes/budget.md +1 -0
- package/docs/agent-notes/bus.md +10 -4
- package/docs/agent-notes/daemon.md +16 -8
- package/docs/agent-notes/local-worker.md +1 -0
- package/docs/agent-notes/tasks.md +10 -4
- package/docs/agent-notes/tests.md +8 -4
- package/docs/design/adapters.md +108 -0
- package/docs/design/agent-hub-architecture.md +58 -0
- package/docs/design/bus.md +147 -1
- package/docs/design/task-board.md +100 -0
- package/docs/dogfood.md +29 -8
- package/docs/events.md +3 -0
- package/docs/memory.md +17 -1
- package/docs/operations.md +410 -86
- package/docs/profiles.md +35 -0
- package/docs/quickstart.md +25 -5
- package/docs/sdlc.md +1 -1
- package/docs/security.md +182 -62
- package/docs/smoke.md +13 -2
- package/docs/specs/2026-09-19-agent-hub-design.md +84 -14
- package/docs/specs/2026-09-19-multi-project-design.md +7 -2
- package/docs/specs/2026-10-12-office-view-design.md +86 -3
- package/docs/verification/2026-11-03-0.30.0.md +54 -0
- package/docs/verification/2026-11-03-agy-1.4.12-launcher-nonce.md +42 -0
- package/docs/verification/2026-11-03-agy-1.4.12.md +90 -0
- package/docs/verification/2026-11-03-agy-tui-close.md +49 -0
- package/docs/verification/2026-11-03-grok-1.0.62-t0.md +150 -0
- package/docs/verification/2026-11-03-pi-150-compat.md +40 -0
- package/docs/verification/2026-11-03-pi-title-api.md +26 -0
- package/docs/verification/2026-11-03-qwen-0265-acp.md +55 -0
- package/docs/verification/2026-11-03-qwen-0265-dual-output.md +40 -0
- package/docs/verification/2026-11-04-1171-channel-client-evidence.md +31 -0
- package/docs/verification/2026-11-05-0.31.0.md +182 -0
- package/docs/verification/2026-11-05-claude-implementer-2.1.318.md +70 -0
- package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +257 -0
- package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -0
- package/docs/verification/2026-11-06-rooms-presence-cost.md +152 -0
- package/docs/verification/claude-shell-wrapper-native.py +1 -1
- package/docs/verification/dogfood/2026-11-03.md +3095 -0
- package/docs/verification/pi-150-model-free.ts +57 -0
- package/docs/verification/pi-150-native.mjs +21 -0
- package/docs/verification/pi-150-title-api.mjs +31 -0
- package/docs/verified.json +14 -14
- package/package.json +3 -4
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +4565 -3617
- package/src/adapters/acp.ts +5 -4
- package/src/adapters/channel-peer.ts +72 -3
- package/src/adapters/claude-channel-evidence.ts +52 -0
- package/src/adapters/claude-channel.ts +61 -18
- package/src/adapters/claude-implementer.ts +29 -7
- package/src/adapters/claude-print-child.ts +12 -2
- package/src/adapters/codex-appserver.ts +13 -12
- package/src/adapters/compat.ts +13 -13
- package/src/adapters/definitions/builtin-metadata.ts +13 -13
- package/src/adapters/definitions/qwen.ts +1 -0
- package/src/adapters/local-worker.ts +82 -23
- package/src/adapters/pi.ts +140 -16
- package/src/adapters/registry-types.ts +16 -0
- package/src/adapters/registry.ts +4 -0
- package/src/adapters/reviewer-print.ts +50 -11
- package/src/broker/server.ts +44 -13
- package/src/broker/store.ts +38 -8
- package/src/cli/answer-args.ts +50 -0
- package/src/cli/attention.ts +17 -2
- package/src/cli/autonomy.ts +66 -1
- package/src/cli/backlog-return-args.ts +37 -0
- package/src/cli/console-question.ts +110 -0
- package/src/cli/console-state.ts +82 -17
- package/src/cli/console.ts +54 -1
- package/src/cli/handoff-args.ts +15 -5
- package/src/cli/handoff-output.ts +15 -2
- package/src/cli/help.ts +10 -5
- package/src/cli/identity.ts +21 -4
- package/src/cli/main.ts +167 -16
- package/src/cli/memory.ts +107 -3
- package/src/cli/output.ts +29 -6
- package/src/cli/pet.ts +4 -4
- package/src/cli/results-args.ts +30 -0
- package/src/cli/runtime-version-warning.ts +50 -0
- package/src/cli/status-lines.ts +41 -1
- package/src/cli/tail-render.ts +1 -0
- package/src/cli/upgrade-interactive.ts +14 -2
- package/src/cli/worktree-tidy.ts +158 -0
- package/src/cli/worktrees-output.ts +43 -0
- package/src/hub/action-class.ts +9 -1
- package/src/hub/board.ts +396 -20
- package/src/hub/bus.ts +497 -57
- package/src/hub/completion-detail.ts +50 -0
- package/src/hub/completions-store.ts +168 -0
- package/src/hub/completions.ts +120 -0
- package/src/hub/conductor.ts +4 -3
- package/src/hub/config-apply.ts +8 -5
- package/src/hub/config-reload.ts +10 -1
- package/src/hub/control-client.ts +40 -4
- package/src/hub/daemon.ts +1795 -142
- package/src/hub/delivery-failure.ts +14 -0
- package/src/hub/delivery-hints.ts +14 -4
- package/src/hub/delivery-journal.ts +751 -42
- package/src/hub/delivery-superseded.ts +4 -1
- package/src/hub/dogfood-read.ts +62 -9
- package/src/hub/envelope.ts +3 -3
- package/src/hub/events.ts +6 -0
- package/src/hub/handoff.ts +145 -6
- package/src/hub/hub-tools.ts +66 -11
- package/src/hub/manager.ts +67 -3
- package/src/hub/memory-policy.ts +44 -1
- package/src/hub/memory-sync-policy.ts +109 -0
- package/src/hub/memory-sync-state.ts +145 -0
- package/src/hub/no-writes.ts +1 -1
- package/src/hub/operational-settings.ts +11 -2
- package/src/hub/operational-view.ts +12 -1
- package/src/hub/peer-doing.ts +33 -3
- package/src/hub/peers.ts +32 -2
- package/src/hub/planning.ts +6 -1
- package/src/hub/question-desk.ts +201 -0
- package/src/hub/question-detach.ts +6 -0
- package/src/hub/questions.ts +260 -0
- package/src/hub/research-evidence.ts +106 -0
- package/src/hub/restart.ts +14 -0
- package/src/hub/review-anchor.ts +181 -0
- package/src/hub/review-brief.ts +13 -0
- package/src/hub/review-comments.ts +115 -0
- package/src/hub/review-findings.ts +7 -2
- package/src/hub/review-material.ts +76 -0
- package/src/hub/routing.ts +3 -1
- package/src/hub/settings.ts +3 -0
- package/src/hub/task-sweep.ts +2 -0
- package/src/hub/tasks.ts +607 -71
- package/src/hub/ui.ts +2 -0
- package/src/hub/worktree-prs.ts +100 -0
- package/src/hub/worktree-shelf.ts +246 -0
- package/src/memory/capture.ts +5 -2
- package/src/pet/config.ts +25 -13
- package/src/pi/extension.ts +43 -3
- package/src/rooms/link.ts +209 -21
- package/src/rooms/remote-peer.ts +186 -19
- package/src/rooms/service.ts +338 -22
- package/src/rooms/store.ts +437 -65
- package/src/rooms/wire.ts +24 -6
- package/src/ui/index.html +271 -17
- package/src/ui/needs.ts +10 -4
- package/src/ui/office-model.ts +64 -8
- package/src/ui/office-space.ts +1 -0
- package/src/ui/plugin-questions.ts +56 -0
- package/src/ui/plugin-results.ts +81 -0
- package/src/ui/plugin.ts +219 -28
- package/src/pet/window.html +0 -114
- package/src/pet/window.jxa.js +0 -122
- package/src/pet/window.ts +0 -154
package/src/broker/store.ts
CHANGED
|
@@ -303,6 +303,8 @@ export class BrokerStore {
|
|
|
303
303
|
fail("corrupt");
|
|
304
304
|
}
|
|
305
305
|
}
|
|
306
|
+
// No recipient is connected when a broker opens. Ephemeral presence never survives that boundary.
|
|
307
|
+
this.transaction(() => { this.removeEphemeral(); });
|
|
306
308
|
// Recover only structurally valid public/ciphertext queue rows, then enforce the current caps.
|
|
307
309
|
this.sweep();
|
|
308
310
|
const stats = this.stats();
|
|
@@ -358,7 +360,7 @@ export class BrokerStore {
|
|
|
358
360
|
return { changed: true, revoked };
|
|
359
361
|
}
|
|
360
362
|
|
|
361
|
-
enqueue(proof: VerifiedPublicFrame, authorizedSender: string): { digest: string; duplicate: boolean } {
|
|
363
|
+
enqueue(proof: VerifiedPublicFrame, authorizedSender: string, onlineRecipients: readonly string[] = []): { digest: string; duplicate: boolean } {
|
|
362
364
|
this.assertOpen();
|
|
363
365
|
const wire = checkedProofFrame(proof);
|
|
364
366
|
if (authorizedSender !== proof.sender) fail("untrusted");
|
|
@@ -402,16 +404,24 @@ export class BrokerStore {
|
|
|
402
404
|
}
|
|
403
405
|
if (order < 0n) fail("conflict");
|
|
404
406
|
}
|
|
407
|
+
if (proof.ephemeral) {
|
|
408
|
+
// ponytail: while A awaits C, A's broadcast reply to B may replace C's request;
|
|
409
|
+
// C waits for A's next state change/reconnect. Task #736 idle refresh or
|
|
410
|
+
// per-(sender,recipient) coalescing closes this latest-per-sender ceiling.
|
|
411
|
+
this.removeEphemeral(proof.room, proof.sender);
|
|
412
|
+
const connected = new Set(onlineRecipients);
|
|
413
|
+
recipients = recipients.filter(recipient => connected.has(recipient));
|
|
414
|
+
}
|
|
405
415
|
const counts = this.db.query<{ count: number; bytes: number }, []>("SELECT COUNT(*) AS count, COALESCE(SUM(length(wire)),0) AS bytes FROM queued_frames").get();
|
|
406
416
|
const room = this.db.query<{ count: number; bytes: number }, [string]>("SELECT COUNT(*) AS count, COALESCE(SUM(length(wire)),0) AS bytes FROM queued_frames WHERE room=?").get(proof.room);
|
|
407
|
-
if (byteLength > this.limits.packetBytes || byteLength > this.limits.queuedBytesPerRoom || (room?.count ?? 0) >= this.limits.queuedPerRoom ||
|
|
417
|
+
if (byteLength > this.limits.packetBytes || byteLength > this.limits.queuedBytesPerRoom || recipients.length > 0 && ((room?.count ?? 0) >= this.limits.queuedPerRoom ||
|
|
408
418
|
(room?.bytes ?? 0) + byteLength > this.limits.queuedBytesPerRoom || (counts?.count ?? 0) >= this.limits.queuedGlobal ||
|
|
409
|
-
(counts?.bytes ?? 0) + byteLength > this.limits.packetBytes) fail("full");
|
|
419
|
+
(counts?.bytes ?? 0) + byteLength > this.limits.packetBytes)) fail("full");
|
|
410
420
|
const expiresAt = writeNow + this.limits.queueTtlMs;
|
|
411
421
|
if (!Number.isSafeInteger(expiresAt)) fail("invalid");
|
|
412
422
|
this.db.query("INSERT INTO sender_floors(room,sender,seq,digest) VALUES(?,?,?,?) ON CONFLICT(room,sender) DO UPDATE SET seq=excluded.seq,digest=excluded.digest")
|
|
413
423
|
.run(proof.room, proof.sender, proof.seq, proof.digest);
|
|
414
|
-
this.db.query("INSERT INTO queued_frames(room,sender,seq,digest,wire,recipients,created_at,expires_at,epoch) VALUES(?,?,?,?,?,?,?,?,?)")
|
|
424
|
+
if (recipients.length) this.db.query("INSERT INTO queued_frames(room,sender,seq,digest,wire,recipients,created_at,expires_at,epoch) VALUES(?,?,?,?,?,?,?,?,?)")
|
|
415
425
|
.run(proof.room, proof.sender, proof.seq, proof.digest, wire, JSON.stringify(recipients), writeNow, expiresAt, proof.epoch);
|
|
416
426
|
result = { digest: proof.digest, duplicate: false };
|
|
417
427
|
});
|
|
@@ -423,7 +433,24 @@ export class BrokerStore {
|
|
|
423
433
|
return fail("invalid");
|
|
424
434
|
}
|
|
425
435
|
|
|
426
|
-
|
|
436
|
+
/** A disconnected member never retains ephemeral presence, while durable work stays queued. */
|
|
437
|
+
disconnect(room: string, recipient: string): void {
|
|
438
|
+
this.transaction(() => { this.removeEphemeral(room, undefined, recipient); });
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
private removeEphemeral(room?: string, sender?: string, recipient?: string): void {
|
|
442
|
+
const rows = this.db.query<{ rowid: number; wire: Uint8Array; recipients: string }, [string | null, string | null, string | null, string | null]>(
|
|
443
|
+
"SELECT rowid,wire,recipients FROM queued_frames WHERE (? IS NULL OR room=?) AND (? IS NULL OR sender=?)",
|
|
444
|
+
).all(room ?? null, room ?? null, sender ?? null, sender ?? null);
|
|
445
|
+
for (const row of rows) {
|
|
446
|
+
if (!decodeFrame(row.wire).ephemeral) continue;
|
|
447
|
+
const remaining = recipient === undefined ? [] : parseRecipients(row.recipients).filter(id => id !== recipient);
|
|
448
|
+
if (remaining.length) this.db.query("UPDATE queued_frames SET recipients=? WHERE rowid=?").run(JSON.stringify(remaining), row.rowid);
|
|
449
|
+
else this.db.query("DELETE FROM queued_frames WHERE rowid=?").run(row.rowid);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
next(room: string, recipient: string): { digest: string; wire: Uint8Array; expiresAt: number } | undefined {
|
|
427
454
|
this.assertOpen(); let now = 0;
|
|
428
455
|
let state = this.rooms.get(room);
|
|
429
456
|
if (!state) return undefined;
|
|
@@ -431,13 +458,16 @@ export class BrokerStore {
|
|
|
431
458
|
if (state) this.rooms.set(room, state);
|
|
432
459
|
if (!state?.current.members.some(member => member.fingerprint === recipient)) return undefined;
|
|
433
460
|
const previous = state.previous && state.previous.expiresAt > now ? state.previous.roster.epoch : state.current.epoch;
|
|
434
|
-
const rows = this.db.query<{ id: number; digest: string; recipients: string }, [string, number, string, string]>(
|
|
435
|
-
"SELECT rowid AS id,digest,recipients FROM queued_frames WHERE room=? AND expires_at>? AND epoch IN (?,?) ORDER BY rowid",
|
|
461
|
+
const rows = this.db.query<{ id: number; digest: string; recipients: string; epoch: string; expiresAt: number }, [string, number, string, string]>(
|
|
462
|
+
"SELECT rowid AS id,digest,recipients,epoch,expires_at AS expiresAt FROM queued_frames WHERE room=? AND expires_at>? AND epoch IN (?,?) ORDER BY rowid",
|
|
436
463
|
).all(room, now, state.current.epoch, previous);
|
|
437
464
|
for (const row of rows) {
|
|
438
465
|
if (!parseRecipients(row.recipients).includes(recipient)) continue;
|
|
439
466
|
const packet = this.db.query<{ wire: Uint8Array }, [number]>("SELECT wire FROM queued_frames WHERE rowid=?").get(row.id);
|
|
440
|
-
if (packet?.wire instanceof Uint8Array)
|
|
467
|
+
if (packet?.wire instanceof Uint8Array) {
|
|
468
|
+
const expiresAt = row.epoch === state.current.epoch ? row.expiresAt : Math.min(row.expiresAt, state.previous!.expiresAt);
|
|
469
|
+
return { digest: row.digest, wire: new Uint8Array(packet.wire), expiresAt };
|
|
470
|
+
}
|
|
441
471
|
}
|
|
442
472
|
return undefined;
|
|
443
473
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { QUESTION_SET } from "../hub/questions.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* #1053 T4: `ahub answer <set> <question>=<option>[,<option>]... [--other <text>]...`. Pure: it turns argv into the `answers` list the hub validates
|
|
5
|
+
* against the stored row, and never decides whether an answer is valid (the store does, with a reason that names a question by position).
|
|
6
|
+
*
|
|
7
|
+
* `--other <text>` is the free text of the one question named, or `--other <question>=<text>` when several are named; a question named with no options
|
|
8
|
+
* (`extras=`) answers with its other text alone. The question ids are the agent's, bounded by the same pattern the store uses.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const ID = /^[A-Za-z0-9_.-]{1,64}$/;
|
|
12
|
+
export interface ParsedAnswer { set: string; answers: { questionId: string; optionIds: string[]; other?: string }[] }
|
|
13
|
+
export type AnswerArgs = { ok: true; value: ParsedAnswer } | { ok: false; error: string };
|
|
14
|
+
export const ANSWER_USAGE = "usage: ahub answer <set> <question>=<option>[,<option>] [--other <text>] (ahub answer lists the open questions)";
|
|
15
|
+
|
|
16
|
+
export function parseAnswerArgs(args: readonly string[]): AnswerArgs {
|
|
17
|
+
const fail = (error: string): { ok: false; error: string } => ({ ok: false, error });
|
|
18
|
+
const [set, ...rest] = args;
|
|
19
|
+
if (!set || !QUESTION_SET.test(set)) return fail(`${ANSWER_USAGE}; the set looks like qs-0123456789ab`);
|
|
20
|
+
const picks = new Map<string, string[]>(), others: string[] = [];
|
|
21
|
+
for (let index = 0; index < rest.length; index++) {
|
|
22
|
+
const word = rest[index]!;
|
|
23
|
+
if (word === "--other" || word.startsWith("--other=")) {
|
|
24
|
+
const value = word === "--other" ? rest[++index] : word.slice("--other=".length);
|
|
25
|
+
if (value === undefined || value === "") return fail("--other needs the text to give");
|
|
26
|
+
others.push(value);
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (word.startsWith("--")) return fail(`${ANSWER_USAGE}; unknown flag ${word.split("=")[0]}`);
|
|
30
|
+
const at = word.indexOf("=");
|
|
31
|
+
if (at < 1) return fail(`${ANSWER_USAGE}; "${word.slice(0, 40)}" is not <question>=<option>`);
|
|
32
|
+
const question = word.slice(0, at);
|
|
33
|
+
if (!ID.test(question)) return fail("a question id is 1 to 64 characters of letters, digits, dot, dash or underscore");
|
|
34
|
+
if (picks.has(question)) return fail(`question ${question} is answered twice`);
|
|
35
|
+
const options = word.slice(at + 1).split(",").filter(Boolean);
|
|
36
|
+
if (options.some(option => !ID.test(option))) return fail(`question ${question}: an option id is 1 to 64 characters of letters, digits, dot, dash or underscore`);
|
|
37
|
+
picks.set(question, options);
|
|
38
|
+
}
|
|
39
|
+
if (!picks.size) return fail(`${ANSWER_USAGE}; name at least one question`);
|
|
40
|
+
const other = new Map<string, string>();
|
|
41
|
+
for (const text of others) {
|
|
42
|
+
const at = text.indexOf("=");
|
|
43
|
+
const named = at > 0 && picks.has(text.slice(0, at)) ? text.slice(0, at) : undefined;
|
|
44
|
+
const target = named ?? (picks.size === 1 ? [...picks.keys()][0]! : undefined);
|
|
45
|
+
if (target === undefined) return fail("with several questions, give --other <question>=<text>");
|
|
46
|
+
if (other.has(target)) return fail(`question ${target} has two --other texts`);
|
|
47
|
+
other.set(target, named ? text.slice(at + 1) : text);
|
|
48
|
+
}
|
|
49
|
+
return { ok: true, value: { set, answers: [...picks].map(([questionId, optionIds]) => ({ questionId, optionIds, ...(other.has(questionId) ? { other: other.get(questionId)! } : {}) })) } };
|
|
50
|
+
}
|
package/src/cli/attention.ts
CHANGED
|
@@ -10,11 +10,15 @@ export interface AttentionState {
|
|
|
10
10
|
recoveryNeed?: { id?: string; running: boolean; since?: number };
|
|
11
11
|
queue?: readonly { id: string; peer?: string; state: string }[];
|
|
12
12
|
attentionFacts?: { kind: "review" | "offline" | "stall" | "offer"; id: number; peer?: string }[];
|
|
13
|
+
/** #1053: open questions; only the set, the peer and the time reach the model. */
|
|
14
|
+
questions?: readonly { set: string; peer: string; created: number }[];
|
|
15
|
+
/** #1054: unread results from the hub (ids, peers and settle times only). */
|
|
16
|
+
results?: readonly { id: string; peer?: string; since?: number }[];
|
|
13
17
|
}
|
|
14
18
|
/** Allowlisted adapter between console-owned facts and the shared body-free model. */
|
|
15
19
|
export function consoleNeeds(state: AttentionState, now: number): Needs {
|
|
16
|
-
const input: NeedsInput = { approvals: state.approvals.map(row => ({ id: row.id, peer: row.peer, expiresAt: row.expiresAt })), defaults: state.permissionDefaults.map(row => ({ peer: row.peer })), peers: Object.fromEntries(Object.entries(state.peers).map(([peer,row]) => [peer,{ state: row.state, attached: row.attached, needsReview: row.needsReview, heldBy: row.heldBy }])), budget: Object.fromEntries(Object.entries(state.budget).map(([peer,row]) => [peer,{ ...(row.paused ? { paused: { since: row.paused.since, resetsAt: row.paused.resetsAt } } : {}) }])), messages: (state.attentionMessages ?? []).map(row => ({ id: row.id, peer: row.peer, important: row.important, since: row.since })), storageBlocked: state.storageBlocked, recovery: state.recoveryNeed };
|
|
17
|
-
const result = needs({ ...input, facts: state.attentionFacts }, now);
|
|
20
|
+
const input: NeedsInput = { approvals: state.approvals.map(row => ({ id: row.id, peer: row.peer, expiresAt: row.expiresAt })), defaults: state.permissionDefaults.map(row => ({ peer: row.peer })), peers: Object.fromEntries(Object.entries(state.peers).map(([peer,row]) => [peer,{ state: row.state, attached: row.attached, needsReview: row.needsReview, heldBy: row.heldBy }])), budget: Object.fromEntries(Object.entries(state.budget).map(([peer,row]) => [peer,{ ...(row.paused ? { paused: { since: row.paused.since, resetsAt: row.paused.resetsAt } } : {}) }])), messages: (state.attentionMessages ?? []).map(row => ({ id: row.id, peer: row.peer, important: row.important, since: row.since })), storageBlocked: state.storageBlocked, recovery: state.recoveryNeed, questions: (state.questions ?? []).map(row => ({ set: row.set, peer: row.peer, since: row.created })) };
|
|
21
|
+
const result = needs({ ...input, facts: state.attentionFacts, results: state.results }, now);
|
|
18
22
|
// Panel snapshots know each held row; enrich only ids, without changing the status-derived count.
|
|
19
23
|
const used = new Set<string | number>();
|
|
20
24
|
for (const item of result.items) if (item.kind === "delivery") {
|
|
@@ -50,7 +54,17 @@ export function attentionFacts(raw: unknown): NonNullable<AttentionState["attent
|
|
|
50
54
|
});
|
|
51
55
|
}
|
|
52
56
|
|
|
57
|
+
/** The unread results in the authenticated status reply's attention items: a `result` item at level `watch` with a completion id, never any text. */
|
|
58
|
+
export function attentionResults(raw: unknown): NonNullable<AttentionState["results"]> {
|
|
59
|
+
if (!Array.isArray(raw)) return [];
|
|
60
|
+
return raw.flatMap<NonNullable<AttentionState["results"]>[number]>(row => {
|
|
61
|
+
if (!row || typeof row !== "object" || row.kind !== "result" || row.level !== "watch" || typeof row.id !== "string" || !/^cmp-[a-f0-9]{12}$/.test(row.id)) return [];
|
|
62
|
+
return [{ id: row.id, ...(typeof row.peer === "string" && /^[A-Za-z0-9_.:-]{1,128}$/.test(row.peer) ? { peer: row.peer } : {}), ...(Number.isSafeInteger(row.since) && row.since >= 0 ? { since: row.since } : {}) }];
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
53
66
|
export function attentionCommand(item: NeedItem): string {
|
|
67
|
+
if (item.kind === "result") return `ahub results${item.id ? ` --read ${item.id}` : ""}`;
|
|
54
68
|
if (item.kind === "offer") return `open ${item.peer}: task #${item.id} is waiting for it; ahub task show ${item.id}`;
|
|
55
69
|
if (item.kind === "stall") return `ahub task show ${item.id}; configure the peer or ahub task assign ${item.id} <peer>, or ahub task cancel ${item.id} --reason <text>`;
|
|
56
70
|
if (item.kind === "delivery") return `ahub queue list${item.id ? `; ahub queue show ${item.id}` : ""}`;
|
|
@@ -60,5 +74,6 @@ export function attentionCommand(item: NeedItem): string {
|
|
|
60
74
|
if (item.kind === "recovery") return `ahub recovery status${item.id ? ` ${item.id}` : ""}`;
|
|
61
75
|
if (item.kind === "budget") return "ahub budget";
|
|
62
76
|
if (item.kind === "default") return "ahub permission";
|
|
77
|
+
if (item.kind === "question") return `A answers it here; ahub answer lists it and ${item.id ? `ahub answer ${item.id} <question>=<option>` : "ahub answer <set> <question>=<option>"} answers`;
|
|
63
78
|
return item.peer ? `ahub say @${item.peer} <message>` : "ahub console";
|
|
64
79
|
}
|
package/src/cli/autonomy.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import type { AutonomyPeerStatus } from "../hub/autonomy.ts";
|
|
3
|
+
import type { ClaudeHomeCheck } from "./claude-homes.ts";
|
|
4
|
+
|
|
1
5
|
export { autonomyEnvironment } from "../hub/autonomy.ts";
|
|
2
6
|
export const AUTONOMY_USAGE = "usage: ahub autonomy on [--all-projects] [--yes] | off [--all-projects] | status [--json]";
|
|
3
7
|
export interface AutonomyCliPorts {
|
|
@@ -40,5 +44,66 @@ export async function runAutonomy(args: string[], ports: AutonomyCliPorts): Prom
|
|
|
40
44
|
}
|
|
41
45
|
if (action === "status" && ports.decorateStatus) reply = await ports.decorateStatus(reply);
|
|
42
46
|
if (action === "status" && args.includes("--json")) ports.print(JSON.stringify(reply.status));
|
|
43
|
-
else
|
|
47
|
+
else {
|
|
48
|
+
const lines = action === "status" && reply.status && typeof reply.status === "object"
|
|
49
|
+
? renderAutonomyStatus(reply.status as Record<string, unknown>) : null;
|
|
50
|
+
for (const line of lines ?? (Array.isArray(reply.lines) ? reply.lines : typeof reply.text === "string" ? [reply.text] : [])) if (typeof line === "string") ports.print(line);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Human presentation only; the daemon report and JSON wire output stay unchanged. */
|
|
55
|
+
export function renderAutonomyStatus(status: Record<string, unknown>, home = homedir()): string[] | null {
|
|
56
|
+
if (!Array.isArray(status.peers)) return null;
|
|
57
|
+
const peers = status.peers as AutonomyPeerStatus[];
|
|
58
|
+
const machine = status.machineDefault as { enabled: boolean; notice?: string } | undefined;
|
|
59
|
+
const lines = [`Project autonomy · environment ${status.environment}${machine ? ` · machine default ${machine.enabled ? "on" : "off"}` : ""}`];
|
|
60
|
+
if (status.runtime === "stopped") lines.push("The hub is stopped; effective runtime modes and retained consent are unknown.");
|
|
61
|
+
lines.push("");
|
|
62
|
+
if (peers.length) {
|
|
63
|
+
const rows = [["PEER", "NOW", "NEXT START"], ...peers.map(peer => [peer.id, peer.effectiveMode, peer.nextMode ?? "unmanaged"])];
|
|
64
|
+
const widths = rows[0]!.map((_, column) => Math.max(...rows.map(row => row[column]!.length)));
|
|
65
|
+
for (const row of rows) lines.push(row.map((value, column) => column < row.length - 1 ? value.padEnd(widths[column]!) : value).join(" "));
|
|
66
|
+
} else lines.push("No peers were inspected.");
|
|
67
|
+
|
|
68
|
+
const floors = new Map<string, { row: AutonomyPeerStatus["floor"][number]; peers: Set<string> }>();
|
|
69
|
+
const classes = new Set<string>(), enforced = new Set<string>();
|
|
70
|
+
const unknowns = new Map<string, Set<string>>();
|
|
71
|
+
for (const peer of peers) {
|
|
72
|
+
for (const row of peer.floor ?? []) {
|
|
73
|
+
classes.add(row.class);
|
|
74
|
+
if (row.status === "hub-enforced" || row.status === "structural-guard") enforced.add(row.class);
|
|
75
|
+
const key = JSON.stringify([row.class, row.status, row.reason]);
|
|
76
|
+
let group = floors.get(key);
|
|
77
|
+
if (!group) { group = { row, peers: new Set() }; floors.set(key, group); }
|
|
78
|
+
group.peers.add(peer.id);
|
|
79
|
+
}
|
|
80
|
+
for (const text of peer.missing ?? []) {
|
|
81
|
+
if ((peer.floor ?? []).some(row => text.startsWith(`${row.class}: `))) continue;
|
|
82
|
+
let group = unknowns.get(text);
|
|
83
|
+
if (!group) { group = new Set(); unknowns.set(text, group); }
|
|
84
|
+
group.add(peer.id);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (peers.length) lines.push("", `Guarded actions (${classes.size} classes; ${enforced.size} enforced for at least one peer)`);
|
|
88
|
+
for (const { row, peers: members } of floors.values()) {
|
|
89
|
+
lines.push(` ${row.class} · ${row.status} · ${members.size === peers.length ? "all peers" : [...members].join(", ")}`, ` ${row.reason}`);
|
|
90
|
+
}
|
|
91
|
+
const projectMissing = Array.isArray(status.missing) ? status.missing.filter((text): text is string => typeof text === "string") : [];
|
|
92
|
+
if (unknowns.size || projectMissing.length) {
|
|
93
|
+
lines.push("", "Unknowns");
|
|
94
|
+
for (const text of projectMissing) lines.push(` project: ${text}`);
|
|
95
|
+
for (const [text, members] of unknowns) lines.push(` ${[...members].join(", ")}: ${text}`);
|
|
96
|
+
}
|
|
97
|
+
if (machine?.notice) lines.push("", machine.notice);
|
|
98
|
+
const notices = status.claudeHomeNotices as string[] | undefined;
|
|
99
|
+
if (Array.isArray(status.claudeHomes) || notices?.length) {
|
|
100
|
+
lines.push("", "Claude homes");
|
|
101
|
+
for (const notice of notices ?? []) lines.push(` ${notice}`);
|
|
102
|
+
for (const profile of (status.claudeHomes ?? []) as ClaudeHomeCheck[]) {
|
|
103
|
+
const path = profile.home.path;
|
|
104
|
+
const display = path === home ? "~" : path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
|
|
105
|
+
lines.push(` ${display}: plugin ${profile.plugin.state}; hook ${profile.hook.state}. ${profile.hook.reason}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return lines;
|
|
44
109
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #1177: `ahub backlog return <task> <revision> <reason...> [--reserved <peer> | --unreserve]`. Pure: argv to the `hub_backlog_return_untouched` arguments, and never a
|
|
3
|
+
* decision about whether the return applies (the hub checks authority, the revision and the reservation). With neither flag the arguments are exactly what the command
|
|
4
|
+
* sent before, so today's behaviour is unchanged.
|
|
5
|
+
*
|
|
6
|
+
* The flags are the two reservation edits the tool already takes as `fields.reserved` (a peer id, or null to clear). They are read wherever they stand among the reason's
|
|
7
|
+
* words, are removed from the reason, and at most one of them may be given: a reason is free text, so the only way to tell a flag from a word of it is that it is one of
|
|
8
|
+
* these exact tokens. `--reserved=<peer>` is the same as `--reserved <peer>`.
|
|
9
|
+
*/
|
|
10
|
+
const PEER = /^[a-z][a-z0-9-]{0,31}$/;
|
|
11
|
+
export type BacklogReturn = { ok: true; value: { reason: string; fields?: { reserved: string | null } } } | { ok: false; error: string };
|
|
12
|
+
export const BACKLOG_RETURN_USAGE = "usage: ahub backlog return <task> <revision> <reason...> [--reserved <peer> | --unreserve]";
|
|
13
|
+
|
|
14
|
+
export function parseBacklogReturn(words: readonly string[]): BacklogReturn {
|
|
15
|
+
const fail = (error: string): { ok: false; error: string } => ({ ok: false, error: `${BACKLOG_RETURN_USAGE}; ${error}` });
|
|
16
|
+
const reason: string[] = [];
|
|
17
|
+
let reserved: string | null | undefined;
|
|
18
|
+
for (let index = 0; index < words.length; index++) {
|
|
19
|
+
const word = words[index]!;
|
|
20
|
+
if (word === "--unreserve") {
|
|
21
|
+
if (reserved !== undefined) return fail("give one of --reserved and --unreserve, once");
|
|
22
|
+
reserved = null;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (word === "--reserved" || word.startsWith("--reserved=")) {
|
|
26
|
+
if (reserved !== undefined) return fail("give one of --reserved and --unreserve, once");
|
|
27
|
+
const peer = word === "--reserved" ? words[++index] : word.slice("--reserved=".length);
|
|
28
|
+
if (peer === undefined || !PEER.test(peer)) return fail("--reserved needs a peer id like codex");
|
|
29
|
+
reserved = peer;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
reason.push(word);
|
|
33
|
+
}
|
|
34
|
+
const text = reason.join(" ").trim();
|
|
35
|
+
if (!text) return fail("a reason is required");
|
|
36
|
+
return { ok: true, value: { reason: text, ...(reserved !== undefined ? { fields: { reserved } } : {}) } };
|
|
37
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #1053 T4: answering a question a peer asked with hub_ask, with the console's keys. Pure: the reducer passes it the open rows and a key, and it answers with the
|
|
3
|
+
* next flow state, the answers once the last question is done, or nothing. The hub validates every answer against the stored row; this only builds them.
|
|
4
|
+
*
|
|
5
|
+
* One question at a time, the oldest open set first: a digit picks that option (single choice) or toggles it (multi, Enter moves on), `o` types free text where the
|
|
6
|
+
* question allows it (Enter keeps it, Esc stops typing), Esc leaves the flow. Answering is neither outward nor destructive, so the last answer is sent with no confirm.
|
|
7
|
+
*/
|
|
8
|
+
import { MAX_OTHER, type AskQuestion } from "../hub/questions.ts";
|
|
9
|
+
|
|
10
|
+
/** What the hub's `questions` read returns for one open set. The text is the person's: it is shown here and never logged. */
|
|
11
|
+
export interface QuestionView { set: string; peer: string; created: number; private?: boolean; questions: AskQuestion[] }
|
|
12
|
+
/** `picks` and `others` are Maps, never plain objects: a question id is the agent's (`constructor`, `toString` and `__proto__` are valid ids), and an object lookup would find an inherited member. */
|
|
13
|
+
export interface QuestionFlow { set: string; index: number; picks: Map<string, string[]>; others: Map<string, string>; typing?: string }
|
|
14
|
+
export interface FlowAnswer { questionId: string; optionIds: string[]; other?: string }
|
|
15
|
+
export interface FlowStep { flow?: QuestionFlow; answers?: FlowAnswer[]; notice?: string }
|
|
16
|
+
|
|
17
|
+
/** Rows from the hub's reply, copied by shape so a malformed row is dropped rather than rendered. */
|
|
18
|
+
export function questionRows(raw: unknown): QuestionView[] {
|
|
19
|
+
if (!Array.isArray(raw)) return [];
|
|
20
|
+
return raw.flatMap<QuestionView>(row => {
|
|
21
|
+
if (!row || typeof row !== "object" || typeof row.set !== "string" || typeof row.peer !== "string" || !Array.isArray(row.questions) || !row.questions.length) return [];
|
|
22
|
+
return [{ set: row.set, peer: row.peer, created: Number.isSafeInteger(row.created) ? row.created : 0, ...(row.private === true ? { private: true } : {}), questions: row.questions as AskQuestion[] }];
|
|
23
|
+
}).sort((a, b) => a.created - b.created || a.set.localeCompare(b.set));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const startFlow = (row: QuestionView): QuestionFlow => ({ set: row.set, index: 0, picks: new Map(), others: new Map() });
|
|
27
|
+
|
|
28
|
+
const printable = (key: string) => key.length > 0 && !key.startsWith("\x1b") && ![...key].some(char => char < " " || char === "\x7f");
|
|
29
|
+
|
|
30
|
+
export function stepFlow(flow: QuestionFlow, row: QuestionView, key: string): FlowStep {
|
|
31
|
+
const question = row.questions[flow.index];
|
|
32
|
+
if (!question) return { notice: "that question has no more parts" };
|
|
33
|
+
const next: QuestionFlow = { ...flow, picks: new Map(flow.picks), others: new Map(flow.others) };
|
|
34
|
+
const advance = (): FlowStep => {
|
|
35
|
+
next.index = flow.index + 1; next.typing = undefined;
|
|
36
|
+
if (next.index < row.questions.length) return { flow: next };
|
|
37
|
+
return { answers: row.questions.map(item => ({ questionId: item.id, optionIds: next.picks.get(item.id) ?? [], ...(next.others.get(item.id) ? { other: next.others.get(item.id)! } : {}) })) };
|
|
38
|
+
};
|
|
39
|
+
if (flow.typing !== undefined) {
|
|
40
|
+
if (key === "\x1b") { next.typing = undefined; return { flow: next }; }
|
|
41
|
+
if (key === "\r" || key === "\n") {
|
|
42
|
+
const text = flow.typing.trim();
|
|
43
|
+
if (!text) { next.typing = undefined; return { flow: next }; }
|
|
44
|
+
next.others.set(question.id, text);
|
|
45
|
+
if (question.multi !== true) next.picks.set(question.id, []);
|
|
46
|
+
return advance();
|
|
47
|
+
}
|
|
48
|
+
if (key === "\x7f" || key === "\b") { next.typing = Array.from(flow.typing).slice(0, -1).join(""); return { flow: next }; }
|
|
49
|
+
if (printable(key)) next.typing = Array.from(flow.typing + key).slice(0, MAX_OTHER).join("");
|
|
50
|
+
return { flow: next };
|
|
51
|
+
}
|
|
52
|
+
if (key === "\x1b") return { notice: "answer cancelled; the question stays open" };
|
|
53
|
+
if (key === "o" && question.allowOther === true) { next.typing = ""; return { flow: next }; }
|
|
54
|
+
if (/^[1-9]$/.test(key)) {
|
|
55
|
+
const option = question.options[Number(key) - 1];
|
|
56
|
+
if (!option) return { flow: next };
|
|
57
|
+
if (question.multi === true) {
|
|
58
|
+
const chosen = new Set(next.picks.get(question.id) ?? []);
|
|
59
|
+
if (chosen.has(option.id)) chosen.delete(option.id); else chosen.add(option.id);
|
|
60
|
+
next.picks.set(question.id, question.options.filter(item => chosen.has(item.id)).map(item => item.id));
|
|
61
|
+
return { flow: next };
|
|
62
|
+
}
|
|
63
|
+
next.picks.set(question.id, [option.id]); next.others.delete(question.id);
|
|
64
|
+
return advance();
|
|
65
|
+
}
|
|
66
|
+
if ((key === "\r" || key === "\n") && question.multi === true && ((next.picks.get(question.id)?.length ?? 0) > 0 || next.others.get(question.id))) return advance();
|
|
67
|
+
return { flow: next };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A pasted chunk while the person is typing an `o` answer: its printable characters join the text, bounded as typed keys are. A paste is only ever text here: a newline
|
|
72
|
+
* in it is a space and an escape or control character is dropped, so a pasted Enter or Esc can never keep, send or leave the answer. Outside text entry it changes nothing.
|
|
73
|
+
*/
|
|
74
|
+
export function pasteFlow(flow: QuestionFlow, pasted: string): QuestionFlow {
|
|
75
|
+
if (flow.typing === undefined) return flow;
|
|
76
|
+
const text = [...pasted.replace(/[\r\n]+/g, " ")].filter(char => printable(char)).join("");
|
|
77
|
+
return { ...flow, typing: Array.from(flow.typing + text).slice(0, MAX_OTHER).join("") };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The question as lines to show in a wrapped area, never the one-line footer: the head, the prompt, then every option numbered with the chosen ones marked. The footer
|
|
82
|
+
* clips a long line, and a digit submits at once, so an option that is not on screen must never be one a key can pick. `text` flattens agent text for a terminal.
|
|
83
|
+
*/
|
|
84
|
+
export function flowBody(flow: QuestionFlow, row: QuestionView, text: (value: unknown) => string): { head: string; prompt: string; options: string[] } | undefined {
|
|
85
|
+
const question = row.questions[flow.index];
|
|
86
|
+
if (!question) return undefined;
|
|
87
|
+
const chosen = new Set(flow.picks.get(question.id) ?? []);
|
|
88
|
+
return {
|
|
89
|
+
head: `${row.peer} asks ${flow.index + 1}/${row.questions.length}`,
|
|
90
|
+
prompt: text(question.prompt),
|
|
91
|
+
options: question.options.map((option, index) => `${index + 1}${question.multi === true ? (chosen.has(option.id) ? "*" : " ") : ""} ${text(option.label)}`),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The footer line while a flow is open: only the keys that act, and the text being typed (its tail, so the cursor stays in view). It names no option and no prompt: those
|
|
97
|
+
* are in `flowBody`'s wrapped area, where they cannot be cut off.
|
|
98
|
+
*/
|
|
99
|
+
export function flowPrompt(flow: QuestionFlow, row: QuestionView, text: (value: unknown) => string, columns = 80): string {
|
|
100
|
+
const question = row.questions[flow.index];
|
|
101
|
+
if (!question) return "";
|
|
102
|
+
const head = `${row.peer} asks ${flow.index + 1}/${row.questions.length}`;
|
|
103
|
+
if (flow.typing !== undefined) {
|
|
104
|
+
const tail = Array.from(text(flow.typing)), room = Math.max(8, columns - 32);
|
|
105
|
+
return `${head} other: ${tail.length > room ? "..." + tail.slice(-room).join("") : tail.join("")}_ Enter keeps Esc back`;
|
|
106
|
+
}
|
|
107
|
+
const chosen = new Set(flow.picks.get(question.id) ?? []);
|
|
108
|
+
const marked = question.multi === true ? question.options.flatMap((option, index) => chosen.has(option.id) ? [String(index + 1)] : []) : [];
|
|
109
|
+
return `${head} number picks${question.allowOther === true ? " o other" : ""}${question.multi === true ? `${marked.length ? ` chosen ${marked.join(",")}` : ""} Enter done` : ""} Esc cancel`;
|
|
110
|
+
}
|