@staix/agent-hub 0.30.0 → 0.32.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 +52 -0
- package/README.md +13 -7
- package/docs/agent-notes/README.md +1 -0
- package/docs/agent-notes/adapters.md +5 -2
- package/docs/agent-notes/budget.md +1 -0
- package/docs/agent-notes/bus.md +11 -4
- package/docs/agent-notes/daemon.md +16 -8
- package/docs/agent-notes/local-worker.md +1 -0
- package/docs/agent-notes/tasks.md +7 -3
- package/docs/agent-notes/tests.md +6 -4
- package/docs/design/adapters.md +52 -0
- package/docs/design/agent-hub-architecture.md +46 -0
- package/docs/design/bus.md +178 -1
- package/docs/design/task-board.md +82 -0
- package/docs/dogfood.md +23 -4
- package/docs/events.md +3 -0
- package/docs/memory.md +17 -1
- package/docs/operations.md +355 -78
- package/docs/profiles.md +35 -0
- package/docs/quickstart.md +24 -5
- package/docs/security.md +178 -60
- package/docs/specs/2026-09-19-agent-hub-design.md +127 -14
- package/docs/specs/2026-09-19-multi-project-design.md +7 -2
- 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 +311 -0
- package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -0
- package/docs/verification/2026-11-06-0.32.0.md +149 -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/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 +4393 -3609
- 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 +3 -3
- package/src/adapters/definitions/builtin-metadata.ts +13 -13
- package/src/adapters/definitions/qwen.ts +1 -0
- package/src/adapters/local-worker.ts +61 -18
- package/src/adapters/pi.ts +48 -6
- package/src/adapters/registry-types.ts +12 -0
- package/src/adapters/registry.ts +2 -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/backlog-return-args.ts +37 -0
- package/src/cli/console-question.ts +110 -0
- package/src/cli/console-state.ts +78 -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 +8 -5
- package/src/cli/identity.ts +13 -3
- package/src/cli/main.ts +128 -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/room.ts +20 -10
- 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/hub/action-class.ts +9 -1
- package/src/hub/board.ts +369 -18
- package/src/hub/bus.ts +511 -53
- 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/config-apply.ts +7 -5
- package/src/hub/config-reload.ts +10 -1
- package/src/hub/control-client.ts +40 -4
- package/src/hub/daemon.ts +1576 -139
- 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/envelope.ts +3 -3
- package/src/hub/events.ts +6 -0
- package/src/hub/handoff.ts +145 -6
- package/src/hub/hub-tools.ts +56 -7
- package/src/hub/manager.ts +59 -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/peers.ts +25 -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/task-sweep.ts +2 -0
- package/src/hub/tasks.ts +577 -63
- package/src/memory/capture.ts +5 -2
- package/src/pet/config.ts +25 -13
- package/src/rooms/commands.ts +61 -16
- package/src/rooms/link.ts +338 -36
- package/src/rooms/liveness.ts +11 -0
- package/src/rooms/remote-peer.ts +186 -19
- package/src/rooms/service.ts +475 -35
- package/src/rooms/status.ts +8 -1
- package/src/rooms/store.ts +447 -65
- package/src/rooms/wire.ts +24 -6
- package/src/ui/index.html +142 -9
- package/src/ui/needs.ts +10 -4
- package/src/ui/plugin-questions.ts +56 -0
- package/src/ui/plugin-results.ts +81 -0
- package/src/ui/plugin.ts +207 -27
- package/src/pet/window.html +0 -114
- package/src/pet/window.jxa.js +0 -122
- package/src/pet/window.ts +0 -154
package/src/adapters/registry.ts
CHANGED
|
@@ -241,6 +241,8 @@ export function assertNativeCapabilityScope(definition: AdapterDefinition): void
|
|
|
241
241
|
const restricted: [string, boolean][] = [
|
|
242
242
|
["title", definition.title !== undefined],
|
|
243
243
|
["titlePersistence", definition.titlePersistence !== undefined],
|
|
244
|
+
["questionWaitMs", definition.questionWaitMs !== undefined],
|
|
245
|
+
["settle", definition.settle !== undefined],
|
|
244
246
|
["start.acpCommand", declared(definition.start, "acpCommand")],
|
|
245
247
|
["attach.admission", declared(definition.attach, "admission")],
|
|
246
248
|
["budget.resetSoftTokensOnResume", declared(definition.budget, "resetSoftTokensOnResume")],
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { ChildProcess } from "node:child_process";
|
|
2
|
-
import { OwnedPrintChild, PrintChildCleanupError } from "./claude-print-child.ts";
|
|
2
|
+
import { OwnedPrintChild, PrintChildCleanupError, type OwnedInputContext, type OwnedInputObserver } from "./claude-print-child.ts";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import { HUB, frame, sanitize } from "../hub/envelope.ts";
|
|
5
5
|
import { BasePeer } from "../hub/peers.ts";
|
|
6
|
+
import type { DeliveryFailureCause } from "../hub/delivery-failure.ts";
|
|
6
7
|
import type { Envelope } from "../hub/envelope.ts";
|
|
7
8
|
import type { ReviewerMcpSession, ReviewerToolName } from "./reviewer-mcp.ts";
|
|
8
9
|
|
|
@@ -31,6 +32,7 @@ export class ReviewerUnavailableError extends Error {
|
|
|
31
32
|
class ReviewerAdmissionError extends Error {
|
|
32
33
|
constructor(readonly reason: "stale_binding" | "policy_changed") { super("review admission changed"); }
|
|
33
34
|
}
|
|
35
|
+
class ReviewerInputAdmissionError extends Error {}
|
|
34
36
|
|
|
35
37
|
export interface ReviewerPrintUsage { input_tokens?: number; output_tokens?: number; cache_creation_input_tokens?: number; cache_read_input_tokens?: number }
|
|
36
38
|
export interface ReviewerIsolationReceipt {
|
|
@@ -136,6 +138,13 @@ export interface ReviewerPrintOptions {
|
|
|
136
138
|
/** Synchronous host task/privacy/policy fence, checked again after awaited preparation. */
|
|
137
139
|
admit?(env: Envelope): boolean;
|
|
138
140
|
bindingCurrent?(task: number, binding: ReviewerReviewBinding): boolean;
|
|
141
|
+
/** Private-profile observer input provenance only; never a native resume or budget identity. */
|
|
142
|
+
beforeInput?: OwnedInputObserver;
|
|
143
|
+
inputReleased?: OwnedInputObserver;
|
|
144
|
+
/** Host privacy state for the currently attached reviewer input, read only for diagnostics. */
|
|
145
|
+
reviewObserverHeld?(): boolean;
|
|
146
|
+
/** Runs after this fresh child group, task MCP and launch cleanup are confirmed closed. */
|
|
147
|
+
inputFinished?: OwnedInputObserver;
|
|
139
148
|
}
|
|
140
149
|
|
|
141
150
|
type ReviewTarget = Omit<ReviewerFailure, "cause">;
|
|
@@ -166,9 +175,13 @@ export class ReviewerPrintPeer extends BasePeer {
|
|
|
166
175
|
private batch: ReviewBatch | undefined;
|
|
167
176
|
private preparation: { abort: AbortController; done: Promise<ReviewerLaunch> } | undefined;
|
|
168
177
|
private receipt: ReviewerIsolationReceipt | undefined;
|
|
178
|
+
private observerSessionIdValue: string | undefined;
|
|
169
179
|
get isolationReceipt(): ReviewerIsolationReceipt | undefined { return this.receipt; }
|
|
180
|
+
reviewObserverInputHeld(): boolean { return this.opts.reviewObserverHeld?.() ?? false; }
|
|
170
181
|
/** The task whose review the running child was started for (R4: the hub records `fresh` for its verdict). */
|
|
171
182
|
reviewing: number | undefined;
|
|
183
|
+
/** Privacy-only identity for a fresh review profile; never part of resume or budget authority. */
|
|
184
|
+
get observerSessionId(): string | undefined { return this.observerSessionIdValue; }
|
|
172
185
|
private reviewSeq = 0;
|
|
173
186
|
private runningBinding: { task: number; binding: ReviewerReviewBinding } | undefined;
|
|
174
187
|
private activeTrace: { task: number; generation: number; value: ReviewerTraceState } | undefined;
|
|
@@ -234,16 +247,16 @@ export class ReviewerPrintPeer extends BasePeer {
|
|
|
234
247
|
this.setState("offline");
|
|
235
248
|
}
|
|
236
249
|
|
|
237
|
-
protected override onWatchdog(): void { this.abortReview("review watchdog timeout"); }
|
|
250
|
+
protected override onWatchdog(): void { this.abortReview("review watchdog timeout", "timeout"); }
|
|
238
251
|
|
|
239
|
-
private abortReview(reason: "review watchdog timeout" | "review output limit exceeded" | "review policy changed"): void {
|
|
252
|
+
private abortReview(reason: "review watchdog timeout" | "review output limit exceeded" | "review policy changed", failureCause?: DeliveryFailureCause): void {
|
|
240
253
|
const generation = ++this.generation, batch = this.batch, preparation = this.preparation;
|
|
241
254
|
this.reviewing = undefined;
|
|
242
255
|
this.reviewMcpClose?.();
|
|
243
256
|
this.receipt = undefined;
|
|
244
257
|
this.clearWatchdog();
|
|
245
258
|
preparation?.abort.abort();
|
|
246
|
-
this.settle("needs_review", reason);
|
|
259
|
+
this.settle("needs_review", reason, failureCause);
|
|
247
260
|
// Revoke first, then confirm both preparation and the model's whole group are gone before fallback.
|
|
248
261
|
void (async () => {
|
|
249
262
|
if (preparation) await preparation.done.catch(() => {});
|
|
@@ -288,6 +301,11 @@ export class ReviewerPrintPeer extends BasePeer {
|
|
|
288
301
|
try { result = await this.review(env, task, generation); }
|
|
289
302
|
catch (error) {
|
|
290
303
|
if (generation !== this.generation) return;
|
|
304
|
+
if (error instanceof ReviewerInputAdmissionError) {
|
|
305
|
+
failure = { state: started ? "needs_review" : "failed_safe", reason: "review input provenance unavailable" };
|
|
306
|
+
batch.next = envs.length;
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
291
309
|
if (error instanceof ReviewerAdmissionError) {
|
|
292
310
|
this.opts.log?.(`[${this.id}] review of task #${task} skipped: ${error.reason === "stale_binding" ? "stale binding" : "review policy changed"}`);
|
|
293
311
|
if (error.reason === "policy_changed") {
|
|
@@ -395,18 +413,30 @@ export class ReviewerPrintPeer extends BasePeer {
|
|
|
395
413
|
let facts: string | undefined;
|
|
396
414
|
try { facts = this.opts.facts?.(env); } catch (error) { this.opts.log?.(`[${this.id}] review facts for task #${task} failed: ${(error as Error).message}`); }
|
|
397
415
|
const input = `${frame(env)}${facts ? `\n\n${facts}` : ""}`;
|
|
416
|
+
const sessionId = randomUUID();
|
|
417
|
+
const inputContext: OwnedInputContext = Object.freeze({ sessionId, inputId: randomUUID() });
|
|
418
|
+
this.observerSessionIdValue = sessionId;
|
|
419
|
+
try { this.opts.beforeInput?.(inputContext, [env]); }
|
|
420
|
+
catch { this.observerSessionIdValue = undefined; throw new ReviewerInputAdmissionError(); }
|
|
398
421
|
let launch: ReviewerLaunch | undefined;
|
|
399
422
|
const trace: ReviewerTraceState = { reviewCalled: false };
|
|
400
423
|
this.activeTrace = { task, generation, value: trace };
|
|
401
424
|
const mcp = this.opts.backend.taskMcp ? this.opts.taskMcp?.(task, () => generation === this.generation && this.reviewToken(task) !== undefined && this.opts.bindingCurrent?.(task,binding) !== false) : undefined;
|
|
402
|
-
let mcpClosed=false;
|
|
403
|
-
const closeMcp
|
|
425
|
+
let mcpClosed = false, mcpCloseConfirmed = false;
|
|
426
|
+
const closeMcp = () => {
|
|
427
|
+
if (mcpClosed) return;
|
|
428
|
+
mcpClosed = true;
|
|
429
|
+
try { mcp?.close(); mcpCloseConfirmed = true; }
|
|
430
|
+
catch { this.opts.log?.(`[${this.id}] reviewer task MCP cleanup incomplete`); }
|
|
431
|
+
if (this.reviewMcpClose === closeMcp) this.reviewMcpClose = undefined;
|
|
432
|
+
};
|
|
404
433
|
this.reviewMcpClose=closeMcp;
|
|
405
434
|
let ownedChild: OwnedPrintChild | undefined, releasing: Promise<void> | undefined;
|
|
435
|
+
let inputReleased = false, childClosed = false, launchCleanupConfirmed = false;
|
|
406
436
|
let policyTimer: ReturnType<typeof setInterval> | undefined;
|
|
407
437
|
const release = () => releasing ??= Promise.resolve().then(() => launch?.cleanup?.()).then(() => {});
|
|
408
438
|
try {
|
|
409
|
-
launch = await this.prepare(
|
|
439
|
+
launch = await this.prepare(sessionId, input, task, mcp?.config);
|
|
410
440
|
if (generation !== this.generation) throw new ReviewerAdmissionError("stale_binding");
|
|
411
441
|
this.assertAdmission(env, task, binding);
|
|
412
442
|
if (launch.current?.() === false) throw new ReviewerUnavailableError("reviewer isolation changed before child spawn", "binary");
|
|
@@ -415,6 +445,7 @@ export class ReviewerPrintPeer extends BasePeer {
|
|
|
415
445
|
const child = new OwnedPrintChild({ peer: this.id, command: launch.command, args: launch.args,
|
|
416
446
|
cwd: launch.cwd ?? this.opts.cwd, env: launch.env, envMode: launch.envMode, input,
|
|
417
447
|
current: () => generation === this.generation, stop: launch.stop,
|
|
448
|
+
onInputReleased: () => { inputReleased = true; this.opts.inputReleased?.(inputContext, [env]); },
|
|
418
449
|
onError: () => { if (this.child === child) this.child = undefined; },
|
|
419
450
|
onSpawn: () => {
|
|
420
451
|
this.runningBinding={task,binding};
|
|
@@ -441,21 +472,29 @@ export class ReviewerPrintPeer extends BasePeer {
|
|
|
441
472
|
this.childCleanup = release;
|
|
442
473
|
this.childCurrent = launch.watchCurrent ? launch.current : undefined;
|
|
443
474
|
const code = await child.done;
|
|
475
|
+
childClosed = true;
|
|
444
476
|
if (this.child === child) this.child = undefined;
|
|
445
477
|
return { code, quota, trace };
|
|
446
478
|
} finally {
|
|
447
479
|
if (this.activeTrace?.value === trace) this.activeTrace = undefined;
|
|
448
480
|
closeMcp();
|
|
449
481
|
if (policyTimer) clearInterval(policyTimer);
|
|
450
|
-
try {
|
|
451
|
-
|
|
482
|
+
try {
|
|
483
|
+
if (!ownedChild || this.child !== ownedChild) {
|
|
484
|
+
await release(); if (this.childCleanup === release) this.childCleanup = undefined;
|
|
485
|
+
launchCleanupConfirmed = true;
|
|
486
|
+
}
|
|
487
|
+
} catch { this.opts.log?.(`[${this.id}] reviewer isolation cleanup incomplete`); }
|
|
488
|
+
const closedWithoutChild = !ownedChild || ownedChild.process.pid === undefined;
|
|
489
|
+
if (mcpCloseConfirmed && launchCleanupConfirmed && (closedWithoutChild || inputReleased && childClosed))
|
|
490
|
+
try { this.opts.inputFinished?.(inputContext, [env]); } catch { /* receipt cleanup never changes the review verdict */ }
|
|
452
491
|
}
|
|
453
492
|
}
|
|
454
493
|
|
|
455
|
-
private settle(state: "completed" | "failed_safe" | "needs_review", reason?: string): void {
|
|
494
|
+
private settle(state: "completed" | "failed_safe" | "needs_review", reason?: string, failureCause?: DeliveryFailureCause): void {
|
|
456
495
|
const id = this.activeDeliveryId;
|
|
457
496
|
this.activeDeliveryId = undefined;
|
|
458
|
-
if (id) this.delivery({ id, state, ...(reason ? { reason } : {}) });
|
|
497
|
+
if (id) this.delivery({ id, state, ...(reason ? { reason } : {}), ...(failureCause ? { failureCause } : {}) });
|
|
459
498
|
}
|
|
460
499
|
|
|
461
500
|
/** Revokes the verdict authority at once, then stops the child; on failure the handle is kept and the error thrown. */
|
package/src/broker/server.ts
CHANGED
|
@@ -25,6 +25,7 @@ export interface BrokerOptions {
|
|
|
25
25
|
interface Client {
|
|
26
26
|
ws?: ServerWebSocket<Client>;
|
|
27
27
|
room: string;
|
|
28
|
+
ephemeral: boolean;
|
|
28
29
|
challenge: BrokerChallenge;
|
|
29
30
|
phase: "challenge" | "authenticating" | "roster" | "ready" | "closed";
|
|
30
31
|
busy: boolean;
|
|
@@ -35,6 +36,9 @@ interface Client {
|
|
|
35
36
|
rosterDigest?: string;
|
|
36
37
|
rosterDeadline?: number;
|
|
37
38
|
inFlight?: string;
|
|
39
|
+
inFlightEphemeral?: boolean;
|
|
40
|
+
inFlightExpiresAt?: number;
|
|
41
|
+
inFlightEpoch?: string;
|
|
38
42
|
}
|
|
39
43
|
export interface BrokerRuntime {
|
|
40
44
|
readonly port: number;
|
|
@@ -104,8 +108,12 @@ export async function startBroker(options: BrokerOptions, ports: BrokerPorts = {
|
|
|
104
108
|
function close(client: Client, code: string): void {
|
|
105
109
|
if (client.phase === "closed") return;
|
|
106
110
|
client.phase = "closed";
|
|
111
|
+
client.inFlight = undefined; client.inFlightEphemeral = undefined; client.inFlightExpiresAt = undefined; client.inFlightEpoch = undefined;
|
|
107
112
|
queuedBytes -= client.inboxBytes; client.inboxBytes = 0; client.inbox.length = 0;
|
|
108
|
-
if (client.member && members.get(memberKey(client)) === client)
|
|
113
|
+
if (client.member && members.get(memberKey(client)) === client) {
|
|
114
|
+
members.delete(memberKey(client));
|
|
115
|
+
try { store.disconnect(client.room, client.member); } catch { emit("refused", client, "state_unavailable"); }
|
|
116
|
+
}
|
|
109
117
|
if (client.ws) { try { client.ws.close(4403, code); } catch { /* already closed */ } }
|
|
110
118
|
else clients.delete(client);
|
|
111
119
|
}
|
|
@@ -117,9 +125,9 @@ export async function startBroker(options: BrokerOptions, ports: BrokerPorts = {
|
|
|
117
125
|
close(client, "backpressure"); return false;
|
|
118
126
|
}
|
|
119
127
|
const control = (client: Client, value: unknown) => send(client, canonicalBytes(value));
|
|
120
|
-
function refused(client: Client, code: string, terminal = true): void {
|
|
128
|
+
function refused(client: Client, code: string, terminal = true, digest?: string): void {
|
|
121
129
|
emit("refused", client, code);
|
|
122
|
-
control(client, { v: 1, type: "error", code });
|
|
130
|
+
control(client, { v: 1, type: "error", code, ...(code === "queue_full" && digest ? { digest } : {}) });
|
|
123
131
|
if (terminal) close(client, code);
|
|
124
132
|
}
|
|
125
133
|
function active(client: Client): VerifiedPublicRoster {
|
|
@@ -133,16 +141,29 @@ export async function startBroker(options: BrokerOptions, ports: BrokerPorts = {
|
|
|
133
141
|
const roster = active(client);
|
|
134
142
|
client.phase = "roster"; client.rosterDigest = roster.digest;
|
|
135
143
|
client.rosterDeadline = now() + BROKER_SOCKET_LIMITS.rosterAckMs;
|
|
136
|
-
|
|
144
|
+
// Legacy hubs have a closed ready grammar; advertise only after HTTP opt-in.
|
|
145
|
+
if (send(client, publicRosterWire(roster))) control(client, { v: 1, type: "ready", room: client.room, member: client.member, epoch: roster.epoch, digest: roster.digest, ...(client.ephemeral ? { ephemeral: true } : {}) });
|
|
137
146
|
}
|
|
138
147
|
function pump(client: Client): void {
|
|
139
148
|
if (stopping || client.phase === "closed" || !client.member) return;
|
|
140
|
-
active(client);
|
|
149
|
+
const roster = active(client);
|
|
141
150
|
const next = store.next(client.room, client.member);
|
|
142
|
-
if (client.
|
|
143
|
-
|
|
151
|
+
if (client.inFlightEphemeral && client.inFlightEpoch !== roster.epoch) {
|
|
152
|
+
const previous = store.current(client.room)?.previous;
|
|
153
|
+
client.inFlightExpiresAt = previous && previous.roster.epoch === client.inFlightEpoch
|
|
154
|
+
? Math.min(client.inFlightExpiresAt ?? 0, previous.expiresAt) : 0;
|
|
155
|
+
}
|
|
156
|
+
// Replacement retires an ephemeral queue row, not the packet already handed
|
|
157
|
+
// to this socket. Keep its slot until ACK or its original TTL/grace deadline.
|
|
158
|
+
if (client.inFlight && (client.inFlightEphemeral && now() < (client.inFlightExpiresAt ?? 0) || next?.digest === client.inFlight)) return;
|
|
159
|
+
client.inFlight = undefined; client.inFlightEphemeral = undefined; client.inFlightExpiresAt = undefined; client.inFlightEpoch = undefined; // durable expiry/purge still releases its slot.
|
|
144
160
|
if (client.phase !== "ready" || !next) return;
|
|
145
|
-
if (send(client, next.wire))
|
|
161
|
+
if (send(client, next.wire)) {
|
|
162
|
+
client.inFlight = next.digest;
|
|
163
|
+
const frame = decodeFrame(next.wire);
|
|
164
|
+
client.inFlightEphemeral = frame.ephemeral === true;
|
|
165
|
+
client.inFlightExpiresAt = next.expiresAt; client.inFlightEpoch = frame.epoch;
|
|
166
|
+
}
|
|
146
167
|
}
|
|
147
168
|
function safePump(client: Client): void {
|
|
148
169
|
try { pump(client); } catch (error) { refused(client, errorCode(error)); }
|
|
@@ -195,9 +216,19 @@ export async function startBroker(options: BrokerOptions, ports: BrokerPorts = {
|
|
|
195
216
|
const selected = state && (header.epoch === state.current.epoch ? state.current : state.previous?.roster);
|
|
196
217
|
if (!selected) throw new BrokerServerError("wrong_epoch");
|
|
197
218
|
const proof = await (ports.verifyFrame ?? verifyPublicFrame)({ wire: bytes, roster: selected });
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
219
|
+
const onlineRecipients = proof.recipients.filter(recipient => {
|
|
220
|
+
const target = members.get(`${client.room}|${recipient}`);
|
|
221
|
+
return target?.phase === "ready" && (!proof.ephemeral || target.ephemeral);
|
|
222
|
+
});
|
|
223
|
+
try {
|
|
224
|
+
active(client);
|
|
225
|
+
const result = store.enqueue(proof, client.member!, onlineRecipients); // latest membership/grace/caps checked transactionally.
|
|
226
|
+
control(client, { v: 1, type: "stored", digest: result.digest, seq: proof.seq, duplicate: result.duplicate });
|
|
227
|
+
} catch (error) {
|
|
228
|
+
if (errorCode(error) !== "queue_full") throw error;
|
|
229
|
+
refused(client, "queue_full", false, client.ephemeral ? proof.digest : undefined);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
201
232
|
for (const recipient of proof.recipients) {
|
|
202
233
|
const target = members.get(`${client.room}|${recipient}`);
|
|
203
234
|
if (target) safePump(target);
|
|
@@ -215,7 +246,7 @@ export async function startBroker(options: BrokerOptions, ports: BrokerPorts = {
|
|
|
215
246
|
if (packet.digest !== client.inFlight) { refused(client, "unexpected_ack", false); return; }
|
|
216
247
|
active(client);
|
|
217
248
|
store.ack(client.room, client.member!, packet.digest);
|
|
218
|
-
client.inFlight = undefined;
|
|
249
|
+
client.inFlight = undefined; client.inFlightEphemeral = undefined; client.inFlightExpiresAt = undefined; client.inFlightEpoch = undefined;
|
|
219
250
|
control(client, { v: 1, type: "queue_ack", digest: packet.digest });
|
|
220
251
|
safePump(client);
|
|
221
252
|
}
|
|
@@ -259,7 +290,7 @@ export async function startBroker(options: BrokerOptions, ports: BrokerPorts = {
|
|
|
259
290
|
const room = roomId(url.pathname.slice("/v1/rooms/".length));
|
|
260
291
|
if (clients.size >= BROKER_SOCKET_LIMITS.connections) return new Response("busy", { status: 503 });
|
|
261
292
|
const current = store.current(room)?.current;
|
|
262
|
-
const client: Client = { room, challenge: { v: 1, type: "challenge", room, broker: publicUrl, nonce: encodeBase64url(randomBytes(32)), expiresAt: now() + BROKER_SOCKET_LIMITS.authMs, rosterDigest: current?.digest ?? null }, phase: "challenge", busy: false, inbox: [], inboxBytes: 0 };
|
|
293
|
+
const client: Client = { room, ephemeral: req.headers.get("x-agent-hub-ephemeral") === "1", challenge: { v: 1, type: "challenge", room, broker: publicUrl, nonce: encodeBase64url(randomBytes(32)), expiresAt: now() + BROKER_SOCKET_LIMITS.authMs, rosterDigest: current?.digest ?? null }, phase: "challenge", busy: false, inbox: [], inboxBytes: 0 };
|
|
263
294
|
clients.add(client);
|
|
264
295
|
if (host.upgrade(req, { data: client })) return;
|
|
265
296
|
clients.delete(client); return new Response("websocket required", { status: 400 });
|
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
|
}
|
|
@@ -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
|
+
}
|