@staix/agent-hub 0.27.0 → 0.28.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.
Files changed (89) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +11 -3
  3. package/docs/agent-notes/adapters.md +4 -2
  4. package/docs/agent-notes/bus.md +4 -4
  5. package/docs/agent-notes/daemon.md +7 -3
  6. package/docs/agent-notes/tasks.md +5 -0
  7. package/docs/agent-notes/tests.md +6 -5
  8. package/docs/design/adapters.md +76 -0
  9. package/docs/design/agent-hub-architecture.md +67 -0
  10. package/docs/design/agent-hub.md +52 -0
  11. package/docs/design/bus.md +48 -0
  12. package/docs/design/task-board.md +86 -0
  13. package/docs/dogfood.md +16 -1
  14. package/docs/operations.md +308 -12
  15. package/docs/profiles.md +1 -0
  16. package/docs/quickstart.md +11 -3
  17. package/docs/sdlc.md +1 -1
  18. package/docs/security.md +8 -3
  19. package/docs/smoke.md +2 -0
  20. package/docs/specs/2026-09-19-agent-hub-design.md +2 -0
  21. package/docs/verification/2026-10-31-codex-0166-reviewer-isolation.md +2 -0
  22. package/docs/verification/2026-11-02-0.28.0.md +54 -0
  23. package/docs/verification/2026-11-02-autonomy-t4.md +39 -0
  24. package/docs/verification/2026-11-02-codex-0166-reviewer-tool-call.md +53 -0
  25. package/docs/verification/2026-11-02-control-write-after.json +139 -0
  26. package/docs/verification/2026-11-02-control-write-baseline.json +127 -0
  27. package/docs/verification/2026-11-02-control-write-guarded.json +127 -0
  28. package/docs/verification/2026-11-02-control-write-outcomes.md +65 -0
  29. package/docs/verification/2026-11-02-orca-1.4.245-cli-contract.md +51 -0
  30. package/docs/verified.json +13 -13
  31. package/package.json +2 -2
  32. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  33. package/plugins/agent-hub/server.js +338 -112
  34. package/src/adapters/claude-channel.ts +12 -2
  35. package/src/adapters/compat.ts +1 -1
  36. package/src/adapters/definitions/native-cli-claude-setup.ts +31 -9
  37. package/src/adapters/definitions/native-cli-entry-jcode.ts +33 -0
  38. package/src/adapters/definitions/native-cli-entry-qwen.ts +2 -2
  39. package/src/adapters/jcode.ts +37 -4
  40. package/src/adapters/qwen-dual.ts +3 -2
  41. package/src/cli/agents.ts +4 -2
  42. package/src/cli/bench.ts +4 -2
  43. package/src/cli/board-read.ts +25 -0
  44. package/src/cli/claude-homes.ts +2 -2
  45. package/src/cli/console-state.ts +8 -3
  46. package/src/cli/help.ts +6 -5
  47. package/src/cli/identity.ts +9 -1
  48. package/src/cli/init-prompt.ts +179 -0
  49. package/src/cli/init-settings.ts +229 -0
  50. package/src/cli/init.ts +102 -9
  51. package/src/cli/launch.ts +3 -1
  52. package/src/cli/main.ts +106 -35
  53. package/src/cli/output.ts +14 -0
  54. package/src/cli/setup-homes.ts +18 -3
  55. package/src/cli/specs.ts +150 -0
  56. package/src/cli/terminal-recovery.ts +35 -11
  57. package/src/hub/board.ts +56 -3
  58. package/src/hub/bus.ts +54 -7
  59. package/src/hub/child-process.ts +6 -4
  60. package/src/hub/config-apply.ts +4 -1
  61. package/src/hub/config-edit.ts +1 -1
  62. package/src/hub/config-tier.ts +1 -0
  63. package/src/hub/control-client.ts +28 -4
  64. package/src/hub/cost-governance.ts +22 -6
  65. package/src/hub/crash.ts +20 -0
  66. package/src/hub/daemon.ts +304 -56
  67. package/src/hub/delivery-hints.ts +6 -3
  68. package/src/hub/delivery-journal.ts +14 -3
  69. package/src/hub/hub-tools.ts +10 -6
  70. package/src/hub/peers.ts +1 -1
  71. package/src/hub/review-brief.ts +18 -0
  72. package/src/hub/routing.ts +7 -3
  73. package/src/hub/settings.ts +2 -0
  74. package/src/hub/specs-config.ts +43 -0
  75. package/src/hub/specs-task.ts +108 -0
  76. package/src/hub/specs.ts +1008 -0
  77. package/src/hub/task-list-page.ts +75 -0
  78. package/src/hub/task-sweep.ts +10 -1
  79. package/src/hub/tasks.ts +448 -40
  80. package/src/hub/terminal-open.ts +10 -5
  81. package/src/hub/tool-request.ts +131 -0
  82. package/src/ui/office-model.ts +17 -1
  83. package/src/ui/office-space.ts +1 -1
  84. package/templates/config.json +1 -0
  85. package/templates/profiles/dev.json +1 -0
  86. package/templates/profiles/general.json +1 -0
  87. package/templates/profiles/research.json +1 -0
  88. package/templates/profiles/writing.json +1 -0
  89. package/templates/specs.block.md +5 -0
package/src/hub/board.ts CHANGED
@@ -18,6 +18,8 @@ import type { SdlcReceipt } from "./sdlc.ts";
18
18
  import { parseReviewFindings, type ReviewFindingSet } from "./review-findings.ts";
19
19
  import { parseDeliverableRecords, type DeliverableRecord } from "./deliverables.ts";
20
20
  import type { CostPlan, ReviewCost } from "./cost-governance.ts";
21
+ import type { SpecRef, SpecTaskReport } from "./specs-task.ts";
22
+ export type { SpecAck, SpecRef, SpecTaskReport } from "./specs-task.ts";
21
23
  import { campaignHash, parseResearchCampaign, parseResearchHandoff, type ResearchCampaignContract, type ResearchCampaignStatus, type ResearchUnit, type ResearchHandoffV1, type ResearchHandoffRecord } from "./research-campaign.ts";
22
24
 
23
25
  export interface ResearchCampaignRecord {
@@ -133,7 +135,7 @@ export type TaskState = "backlog" | "proposed" | "in_progress" | "in_review" | "
133
135
  export const isClosedTaskState = (state: string): boolean => state === "approved" || state === "cancelled";
134
136
  export interface TaskOffer { envelopeId: string; owner: PeerId; assignmentFlowRevision: number; pullOnly?: boolean }
135
137
  export interface TaskCancellation { reason: string; supersededBy?: string; flowRevision: number; withheld?: true }
136
- export const TASK_STALL_CODES = ["tests", "spec", "roster", "pull-only", "not-attached", "excluded", "owner-admission", "offline", "paused", "failing", "pii", "policy", "cancelled", "unavailable", "no-writes", "queue-count", "queue-age", "queue-age-unknown"] as const;
138
+ export const TASK_STALL_CODES = ["tests", "spec", "network", "worktree", "roster", "pull-only", "not-attached", "excluded", "owner-admission", "offline", "paused", "failing", "pii", "policy", "cancelled", "unavailable", "no-writes", "queue-count", "queue-age", "queue-age-unknown"] as const;
137
139
  export type TaskStallCode = (typeof TASK_STALL_CODES)[number];
138
140
  export interface TaskStall { task: number; signature: string; replanRevision: number; cause: { kind: "reservation" | "no-eligible-peer" | "cancelled-dependency" | "no-writes"; peer?: string; dependency?: number; code: TaskStallCode; turns?: number; acceptance?: number; refusals?: { peer: string; code: TaskStallCode }[] }; at: number }
139
141
 
@@ -201,6 +203,10 @@ export interface HistoryEntry {
201
203
  sweep?: SweepRecord;
202
204
  /** The PII screen did not clear this entry's note (#198): only the console reads it. */
203
205
  withheld?: true;
206
+ /** Public, head-bound design-spec status; acknowledgement text is screened before this is written. */
207
+ specReport?: SpecTaskReport;
208
+ /** Internal identity for incomplete reports without a full commit SHA; never projected publicly. */
209
+ specRefBinding?: string;
204
210
  /** Host evidence at done, never inferred from the owner's summary. */
205
211
  noWrites?: true;
206
212
  /** Host-recorded expected cost and exact review head, never narrative. */
@@ -277,6 +283,31 @@ function reviewerHistory(extra: ReviewerHistory): ReviewerHistory {
277
283
  if (failure && (!id(failure.peer) || !["start_failed", "quota", "no_verdict"].includes(failure.cause) || failure.head !== undefined && (typeof failure.head !== "string" || !failure.head.trim() || failure.head.length > 300 || Buffer.byteLength(failure.head) > 1200 || /\p{Cc}/u.test(failure.head)))) throw new Error("invalid reviewer failure metadata");
278
284
  return { ...(extra.reviewerFamily ? { reviewerFamily: extra.reviewerFamily } : {}), ...(extra.reviewerReason ? { reviewerReason: extra.reviewerReason } : {}), ...(extra.reviewBlocked ? { reviewBlocked: true } : {}), ...(extra.queueReviewHeld ? { queueReviewHeld: true, ...(extra.queueReviewBinding !== undefined ? { queueReviewBinding: extra.queueReviewBinding } : {}) } : {}), ...(failure ? { reviewerFailure: { peer: failure.peer, cause: failure.cause, ...(failure.head ? { head: failure.head } : {}) } } : {}) };
279
285
  }
286
+ function specReportHistory(report: unknown, binding: unknown, event: string): Pick<HistoryEntry, "specReport" | "specRefBinding"> {
287
+ if (!report || typeof report !== "object" || Array.isArray(report) || !["done", "done (checking)"].includes(event)
288
+ || typeof binding !== "string" || !/^[a-f0-9]{64}$/.test(binding)) throw new Error("invalid task spec report metadata");
289
+ const value = report as Record<string, unknown>;
290
+ if (Object.keys(value).some(key => !["head", "complete", "errors", "unchanged", "changed", "truncated", "reason"].includes(key))
291
+ || typeof value.head !== "string" || value.head !== "" && !/^[a-f0-9]{40}$/.test(value.head)
292
+ || value.head === "" && value.complete !== false || typeof value.complete !== "boolean"
293
+ || !Number.isSafeInteger(value.errors) || (value.errors as number) < 0 || (value.errors as number) > 1_000_000
294
+ || value.truncated !== undefined && value.truncated !== true
295
+ || value.reason !== undefined && (typeof value.reason !== "string" || value.reason.length > 500 || /[\p{Cc}\p{Cf}]/u.test(value.reason))
296
+ || !Array.isArray(value.unchanged) || value.unchanged.length > 20 || !Array.isArray(value.changed) || value.changed.length > 20) throw new Error("invalid task spec report metadata");
297
+ const ref = (raw: unknown): SpecRef => {
298
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("invalid task spec report metadata");
299
+ const row = raw as Record<string, unknown>;
300
+ if (Object.keys(row).some(key => !["id", "path", "title", "reason"].includes(key))
301
+ || typeof row.id !== "string" || !row.id.trim() || row.id.length > 160 || /[\p{Cc}\p{Cf}]/u.test(row.id)
302
+ || typeof row.path !== "string" || !row.path.trim() || row.path.length > 1000 || /[\p{Cc}\p{Cf}]/u.test(row.path)
303
+ || typeof row.title !== "string" || !row.title.trim() || row.title.length > 1000 || /[\p{Cc}\p{Cf}]/u.test(row.title)
304
+ || row.reason !== undefined && (typeof row.reason !== "string" || row.reason.length > 1000 || /[\p{Cc}\p{Cf}]/u.test(row.reason))) throw new Error("invalid task spec report metadata");
305
+ return { id: row.id, path: row.path, title: row.title, ...(typeof row.reason === "string" ? { reason: row.reason } : {}) };
306
+ };
307
+ const specReport: SpecTaskReport = { head: value.head, complete: value.complete, errors: value.errors as number,
308
+ unchanged: value.unchanged.map(ref), changed: value.changed.map(ref), ...(value.truncated === true ? { truncated: true } : {}), ...(typeof value.reason === "string" ? { reason: value.reason } : {}) };
309
+ return { specReport, specRefBinding: binding };
310
+ }
280
311
  export interface PlannerBinding { planner: PeerId | null; plannerReason: PlannerReason }
281
312
  function plannerValue(value: unknown): PeerId | null {
282
313
  if (value == null) return null;
@@ -1362,6 +1393,26 @@ export class Board {
1362
1393
  statusTasks(): Pick<Task, "id" | "state" | "owner" | "reviewer">[] {
1363
1394
  return this.db.query("SELECT id, state, owner, reviewer FROM tasks WHERE state NOT IN ('approved','cancelled') ORDER BY id").all() as Pick<Task, "id" | "state" | "owner" | "reviewer">[];
1364
1395
  }
1396
+ /** Fresh privacy classification inputs only; no task history, title, detail or plan decoding. */
1397
+ peerTaskSignals(peer: string): Pick<Task, "state" | "signals">[] {
1398
+ const rows = this.db.query("SELECT state, signals FROM tasks WHERE owner=? OR reviewer=?").all(peer, peer) as { state: unknown; signals: unknown }[];
1399
+ return rows.map(row => {
1400
+ const signals: unknown = JSON.parse(String(row.signals));
1401
+ if (typeof row.state !== "string" || !Array.isArray(signals) || signals.some(signal => typeof signal !== "string")) throw new Error("invalid task privacy projection");
1402
+ return { state: row.state as TaskState, signals: signals as string[] };
1403
+ });
1404
+ }
1405
+ /** Fresh privacy facts for a bounded explicit reference set; no task history or text decoding. */
1406
+ taskPrivacySignals(ids: readonly number[]): Pick<Task, "id" | "signals">[] {
1407
+ if (!Array.isArray(ids) || ids.length > 64 || ids.some(id => !Number.isSafeInteger(id) || id < 1) || new Set(ids).size !== ids.length) throw new Error("invalid task privacy reference set");
1408
+ if (!ids.length) return [];
1409
+ const rows = this.db.query(`SELECT id, signals FROM tasks WHERE id IN (${ids.map(() => "?").join(",")})`).all(...ids) as { id: unknown; signals: unknown }[];
1410
+ return rows.map(row => {
1411
+ const signals: unknown = JSON.parse(String(row.signals));
1412
+ if (!Number.isSafeInteger(row.id) || !Array.isArray(signals) || signals.some(signal => typeof signal !== "string")) throw new Error("invalid task privacy projection");
1413
+ return { id: row.id as number, signals: signals as string[] };
1414
+ });
1415
+ }
1365
1416
  statusReservations(): string[] {
1366
1417
  return (this.db.query("SELECT DISTINCT reserved FROM tasks WHERE state NOT IN ('approved', 'cancelled', 'backlog') AND reserved IS NOT NULL ORDER BY reserved").all() as { reserved: string }[]).map(row => row.reserved);
1367
1418
  }
@@ -1454,7 +1505,7 @@ export class Board {
1454
1505
 
1455
1506
  /** The only way a task changes. Validates the move, records who did what, returns the new row. */
1456
1507
  /** A `plan` in the patch replaces the old one whole: a new plan is the owner's current intent, not an addition. */
1457
- update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "planner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled" | "codeSymbols">>, note?: string, extra: Pick<HistoryEntry, "invocation" | "plannerReason" | "planAuthor" | "selfReview" | "profile" | "sweep" | "reason" | "from" | "capacity" | "conductorManual" | "withheld" | "noWrites" | "reviewCost" | "reviewContext" | "reviewScope" | "evidenceOnly" | "reviewedHead" | "reviewFindings" | "unmetCriteria" | "deliverables" | "checkKey" | "missingKeys" | "claim" | "planObservation" | "planDeparture" | "planDepartureInvalidated" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number, codeSymbolsAuthority?: typeof HOST_CODE_SYMBOLS): Task {
1508
+ update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "planner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled" | "codeSymbols">>, note?: string, extra: Pick<HistoryEntry, "invocation" | "plannerReason" | "planAuthor" | "selfReview" | "profile" | "sweep" | "reason" | "from" | "capacity" | "conductorManual" | "withheld" | "noWrites" | "reviewCost" | "reviewContext" | "reviewScope" | "evidenceOnly" | "reviewedHead" | "reviewFindings" | "unmetCriteria" | "deliverables" | "checkKey" | "missingKeys" | "claim" | "planObservation" | "planDeparture" | "planDepartureInvalidated" | "specReport" | "specRefBinding" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number, codeSymbolsAuthority?: typeof HOST_CODE_SYMBOLS): Task {
1458
1509
  const task = this.get(id);
1459
1510
  if (!task) throw new Error(`no task #${id}`);
1460
1511
  if (expectedFlowRevision !== undefined && (!Number.isSafeInteger(expectedFlowRevision) || expectedFlowRevision < 0 || task.history.length !== expectedFlowRevision)) throw new Error("task flow revision changed");
@@ -1480,6 +1531,8 @@ export class Board {
1480
1531
  const plannerHistory = Object.hasOwn(patch, "planner") || extra.plannerReason !== undefined ? plannerMetadata(planner, extra.plannerReason) : {};
1481
1532
  const authorHistory = Object.hasOwn(extra, "planAuthor") ? { planAuthor: plannerValue(extra.planAuthor) } : {};
1482
1533
  if (extra.selfReview !== undefined && extra.selfReview !== true) throw new Error("invalid self-review metadata");
1534
+ const specMetadata = extra.specReport !== undefined || extra.specRefBinding !== undefined
1535
+ ? specReportHistory(extra.specReport, extra.specRefBinding, event) : {};
1483
1536
  if (extra.conductorManual !== undefined && (extra.conductorManual !== true || by === "user" || by === "hub" || extra.reason !== "manual"
1484
1537
  || !["assigned", "reassigned", "reserved", "unassigned", "reviewer changed"].includes(event))) throw new Error("invalid conductor manual metadata");
1485
1538
  if (extra.invocation && (by !== HUB || event !== "invocation")) throw new Error("invocation audit is host-only");
@@ -1533,7 +1586,7 @@ export class Board {
1533
1586
  && priorHead !== undefined && /^[a-f0-9]{40}$/.test(priorHead);
1534
1587
  const previousHistory = backfill ? task.history.map((entry, index) => index === priorApproval && !entry.reviewedHead
1535
1588
  ? { ...entry, reviewedHead: entry.reviewFindings?.head ?? priorHead } : entry) : task.history;
1536
- const history = [...previousHistory, { at: Date.now(), by, event, ...(extra.invocation ? { invocation: extra.invocation } : {}), ...plannerHistory, ...authorHistory, ...(extra.selfReview === true ? { selfReview: true as const } : {}), ...(planRevision ? { planRevision } : {}), ...(extra.planObservation ? { planObservation: extra.planObservation } : {}), ...(extra.planDeparture ? { planDeparture: extra.planDeparture } : {}), ...(departureInvalidated ? { planDepartureInvalidated: true as const } : {}), ...reviewMetadata, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.capacity === true ? { capacity: true as const } : {}), ...(extra.conductorManual === true ? { conductorManual: true as const } : {}), ...(extra.withheld && (note || reviewFindings) ? { withheld: true as const } : {}), ...(reviewFindings ? { reviewFindings } : {}), ...(extra.reviewedHead ? { reviewedHead: extra.reviewedHead } : {}), ...(unmetCriteria?.length ? { unmetCriteria } : {}), ...(deliverables ? { deliverables } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewScope === "code" ? { reviewScope: "code" as const } : {}), ...(extra.evidenceOnly === true && event === "changes_requested" ? { evidenceOnly: true as const } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}), ...(event === "done" && extra.noWrites === true ? { noWrites: true as const } : {}), ...(["check passed", "check failed"].includes(event) && typeof extra.checkKey === "string" && /^[a-z][a-z0-9_]{0,47}$/.test(extra.checkKey) ? { checkKey: extra.checkKey } : {}), ...(["check passed", "check failed"].includes(event) && Number.isSafeInteger(extra.missingKeys) && extra.missingKeys! >= 0 && extra.missingKeys! <= 100_000 ? { missingKeys: extra.missingKeys } : {}), ...(event === "claim verdict" && extra.claim ? { claim: extra.claim } : {}) }];
1589
+ const history = [...previousHistory, { at: Date.now(), by, event, ...(extra.invocation ? { invocation: extra.invocation } : {}), ...plannerHistory, ...authorHistory, ...(extra.selfReview === true ? { selfReview: true as const } : {}), ...(planRevision ? { planRevision } : {}), ...(extra.planObservation ? { planObservation: extra.planObservation } : {}), ...(extra.planDeparture ? { planDeparture: extra.planDeparture } : {}), ...(departureInvalidated ? { planDepartureInvalidated: true as const } : {}), ...reviewMetadata, ...specMetadata, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.capacity === true ? { capacity: true as const } : {}), ...(extra.conductorManual === true ? { conductorManual: true as const } : {}), ...(extra.withheld && (note || reviewFindings) ? { withheld: true as const } : {}), ...(reviewFindings ? { reviewFindings } : {}), ...(extra.reviewedHead ? { reviewedHead: extra.reviewedHead } : {}), ...(unmetCriteria?.length ? { unmetCriteria } : {}), ...(deliverables ? { deliverables } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewScope === "code" ? { reviewScope: "code" as const } : {}), ...(extra.evidenceOnly === true && event === "changes_requested" ? { evidenceOnly: true as const } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}), ...(event === "done" && extra.noWrites === true ? { noWrites: true as const } : {}), ...(["check passed", "check failed"].includes(event) && typeof extra.checkKey === "string" && /^[a-z][a-z0-9_]{0,47}$/.test(extra.checkKey) ? { checkKey: extra.checkKey } : {}), ...(["check passed", "check failed"].includes(event) && Number.isSafeInteger(extra.missingKeys) && extra.missingKeys! >= 0 && extra.missingKeys! <= 100_000 ? { missingKeys: extra.missingKeys } : {}), ...(event === "claim verdict" && extra.claim ? { claim: extra.claim } : {}) }];
1537
1590
  this.taskWrite(() => {
1538
1591
  const result = this.db.query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, sdlc = ?, task_role = ?, cancelled = ?, updated = ?, planner = ? WHERE id = ?")
1539
1592
  .run(next.state, next.owner, next.reviewer, next.reserved ?? null, JSON.stringify(next.refs), JSON.stringify(next.plan ?? {}), next.rejections, JSON.stringify(next.signals), JSON.stringify(history), next.sdlc ? JSON.stringify(next.sdlc) : null, next.role ?? null, next.cancelled ? JSON.stringify(next.cancelled) : null, Date.now(), planner, id);
package/src/hub/bus.ts CHANGED
@@ -1,8 +1,8 @@
1
- import { remoteEnvelope, appendNote, DIGEST, frame, HUB, isInvocationKind, keepNotes, MAX_HOP, mergePrefaces, newEnvelope, noteLine, parseMarker, quoteNotes, replyAudience, uniqueMemoryLines, type Envelope, type EnvelopeOpts, type PeerId, type PeerState, type Priority } from "./envelope.ts";
1
+ import { remoteEnvelope, appendNote, DIGEST, frame, HUB, USER, isInvocationKind, keepNotes, MAX_HOP, mergePrefaces, newEnvelope, noteLine, parseMarker, quoteNotes, replyAudience, uniqueMemoryLines, type Envelope, type EnvelopeOpts, type PeerId, type PeerState, type Priority } from "./envelope.ts";
2
2
  import type { PeerAdapter, DeliveryReceipt } from "./peers.ts";
3
3
  import type { DoingKind } from "./peer-doing.ts";
4
4
  import type { QueuePressure } from "./queue-backpressure.ts";
5
- import { DeliveryJournal, MAX_ENVELOPE_BODY, type JournalDelivery, type JournalDeliveryState } from "./delivery-journal.ts";
5
+ import { DeliveryJournal, MAX_ENVELOPE_BODY, type JournalDelivery, type JournalDeliveryState, type JournalResolution } from "./delivery-journal.ts";
6
6
  import { DELIVERY_RETRY_WARNING } from "./delivery-hints.ts";
7
7
  import { MAX_DEFERRED_HUB, RecoveryDeferredHubError, validateDeferredHub } from "./deferred-hub.ts";
8
8
  import { parseRemotePeerId } from "../rooms/application.ts";
@@ -617,6 +617,31 @@ export class Bus {
617
617
  this.resolveDelivery(id, row.revision, "retry", "recipient requested one uncertain redelivery", `peer:${peer}`);
618
618
  }
619
619
 
620
+ /** Settle only this peer's own uncertain receipt; this never replays its originals or changes task state. */
621
+ resolveOwnDelivery(peer: string, id: string, revision: number, action: "completed" | "discard", reason: string): void {
622
+ const refuse = (message: string): never => { throw new PreEffectToolRefusal(message); };
623
+ // One message per cause class, each naming its next step. An unknown id, another peer's row and a row that is not needs_review share one message so a refusal never confirms another peer's row.
624
+ if (peer === USER || peer === HUB || remoteId(peer)) return refuse("hub_delivery_resolve settles a local agent's own held delivery; the person settles any other row with ahub queue resolve <delivery-id>");
625
+ if (this.closed || this.recoveryHeld || this.storageError || !this.journal) return refuse("the hub is not accepting delivery changes now (recovery, shutdown or delivery storage); run ahub status, then call hub_delivery_resolve again after it recovers");
626
+ if (this.paused.has(peer) || this.opts.deliveryHeld?.(peer) === true) return refuse("your deliveries are paused or held, so this row cannot be settled now; run ahub status to see which, and call hub_delivery_resolve again once it is released");
627
+ if (!Number.isSafeInteger(revision) || revision < 0 || (action !== "completed" && action !== "discard") || typeof reason !== "string" || !reason.trim() || reason.trim().length > 1000) return refuse("hub_delivery_resolve needs the revision hub_inbox showed (a whole number), action completed or discard, and a reason of 1 to 1000 characters");
628
+ const row = this.queueShow(id), actor = `peer:${peer}`, cleanReason = reason.trim();
629
+ const notYours = "no needs_review delivery with this id is held for you; call hub_inbox to list your held deliveries and their revisions";
630
+ if (!row || row.peer !== peer) return refuse(notYours);
631
+ // An uncertain invocation may still be running: a peer claiming it completed would make the caller wait for a result that may never come. Only the person settles it.
632
+ if (row.originals.some(env => isInvocationKind(env))) return refuse(`delivery ${id} carries an invocation whose outcome is uncertain; only the person settles it (ahub queue show ${id}, then ahub queue resolve ${id})`);
633
+ const previous = this.journal.resolution(id);
634
+ if (previous) {
635
+ if (previous.actor !== actor || previous.action !== action || previous.reason !== cleanReason) return refuse(`delivery ${id} was already settled with a different action or reason; the person can inspect it with ahub queue show ${id}`);
636
+ if (revision !== previous.observedRevision && revision !== row.revision) return refuse("held delivery revision changed; read hub_inbox again for the current revision");
637
+ this.resolveDelivery(id, revision, action, cleanReason, actor);
638
+ return;
639
+ }
640
+ if (row.state !== "needs_review") return refuse(notYours);
641
+ if (row.revision !== revision) return refuse("held delivery revision changed; read hub_inbox again for the current revision");
642
+ this.resolveDelivery(id, revision, action, cleanReason, actor);
643
+ }
644
+
620
645
  private retryOut(peer: string, originals: Envelope[], out: Envelope[]): Envelope[] {
621
646
  if (remoteId(peer)) return out;
622
647
  if (!out.length || !this.journal || !this.journal.retryWarning(peer, originals)) return out;
@@ -631,6 +656,11 @@ export class Bus {
631
656
  });
632
657
  }
633
658
 
659
+ /** #1096: the operator action that settled this delivery, when one did (`queue show` reads it to say what is left to do). */
660
+ deliveryResolution(id: string): JournalResolution | undefined { return this.journal && !this.storageError ? this.journal.resolution(id)?.action : undefined; }
661
+ /** #1096: ids of the deliveries an operator action already settled, in one read; empty without a journal. `queue list` hides them unless asked for all. */
662
+ settledIds(): Set<string> { return this.journal && !this.storageError ? this.journal.resolvedIds() : new Set(); }
663
+
634
664
  resolveDelivery(id: string, revision: number, action: "completed" | "retry" | "discard", reason: string, actor: string = "user"): void {
635
665
  if (!this.journal || this.storageError) throw new Error("delivery journal unavailable");
636
666
  this.trackingQueuePressure();
@@ -638,7 +668,13 @@ export class Bus {
638
668
  if (!record) throw new Error("unknown delivery");
639
669
  const previous = this.journal.resolution(id);
640
670
  if (previous) {
641
- this.journal.resolve(id, revision, action, reason, revision, actor); // verifies identical request; no second enqueue
671
+ // #1096: a row that already carries a resolution is settled. The same request again is idempotent (the journal verifies it); any other request is not a
672
+ // stale read, and `retry` in particular leaves its parent in `failed` for good, because the redelivery is a new row. Say so instead of "stale revision".
673
+ try { this.journal.resolve(id, revision, action, reason, revision, actor); }
674
+ catch (error) {
675
+ if (/stale delivery revision or conflicting operator resolution/.test(error instanceof Error ? error.message : "")) throw new Error(`delivery already resolved by ${previous.action}`);
676
+ throw error;
677
+ }
642
678
  return;
643
679
  }
644
680
  if (action === "retry" && remoteId(record.peer)) {
@@ -648,12 +684,14 @@ export class Bus {
648
684
  if (!Number.isSafeInteger(deadline) || deadline! <= Date.now()) throw new PreEffectToolRefusal("remote original deadline unavailable or expired; discard it or send a new message explicitly");
649
685
  this.checkRemotePublication(record.originals[0]!);
650
686
  }
651
- const recipientConsent = actor === `peer:${record.peer}` && action === "retry" && record.state === "needs_review";
687
+ const recipientConsent = actor === `peer:${record.peer}` && record.state === "needs_review" && (action === "retry" || action === "completed" || action === "discard");
652
688
  // #713: a `queued` delivery was never handed to the peer, so nothing is in flight and a busy peer (a pull-only TUI is busy most of the time) does not block
653
689
  // discarding it. `dispatching` and `accepted` stay refused, and every other resolution keeps the busy fence.
654
690
  if (["dispatching", "accepted"].includes(record.state)) throw new Error("active delivery cannot be resolved");
655
691
  const queuedDiscard = record.state === "queued" && action === "discard";
656
- if (this.peers.get(record.peer)?.state === "busy" && !recipientConsent && !queuedDiscard) throw new Error("peer busy: its delivery cannot be resolved while it works");
692
+ // #1096: a `failed` row was refused before it ran and discarding it touches no queue, so a busy peer has nothing in flight to protect either.
693
+ const failedDiscard = record.state === "failed" && action === "discard";
694
+ if (this.peers.get(record.peer)?.state === "busy" && !recipientConsent && !queuedDiscard && !failedDiscard) throw new Error("peer busy: its delivery cannot be resolved while it works");
657
695
  if (record.revision !== revision) throw new Error("stale delivery revision");
658
696
  if (record.state === "queued" && action === "retry") throw new Error("queued delivery is already scheduled");
659
697
  if (action === "retry" && !remoteId(record.peer) && (this.queues.get(record.peer)?.length ?? 0) + record.originals.length > this.opts.queueCap) throw new Error("recipient queue is full");
@@ -1435,10 +1473,10 @@ export class Bus {
1435
1473
  * terminal row. The completed row keeps the envelopes (`ahub queue show <id>`) to resend by hand; take/confirm in two
1436
1474
  * steps if loss is ever observed.
1437
1475
  */
1438
- pull(id: PeerId): Envelope[] | undefined {
1476
+ pull(id: PeerId, options: { allowNeedsReviewHold?: boolean } = {}): Envelope[] | undefined {
1439
1477
  if (remoteId(id)) return undefined; // A local inbox read is not signed remote queue admission.
1440
1478
  if (this.storageError) throw new Error("delivery journal unavailable");
1441
- if (this.held(id)) return undefined;
1479
+ if (this.held(id) && !(options.allowNeedsReviewHold === true && this.pullMayReadBehindNeedsReview(id))) return undefined;
1442
1480
  const queue = this.queues.get(id) ?? [];
1443
1481
  this.dropIrrelevant(id, queue);
1444
1482
  const preface = this.prefaces.get(id);
@@ -1472,6 +1510,15 @@ export class Bus {
1472
1510
  return out;
1473
1511
  }
1474
1512
 
1513
+ /** Explicit inbox reads may pass only a needs_review hold; every other recovery fence still blocks. */
1514
+ private pullMayReadBehindNeedsReview(id: PeerId): boolean {
1515
+ if (!this.journal || this.closed || this.storageError || !this.isRostered(id) || this.recoveryHeld || this.paused.has(id) || this.opts.deliveryHeld?.(id) === true) return false;
1516
+ if (this.pendingOutcomes.some(row => row.peer === id)) return false;
1517
+ const unresolved = this.journal.unresolved(id);
1518
+ return unresolved.some(row => row.state === "needs_review") &&
1519
+ unresolved.every(row => row.state === "needs_review" || row.state === "queued");
1520
+ }
1521
+
1475
1522
  private async drain(id: PeerId): Promise<void> {
1476
1523
  if (this.storageError || this.draining.has(id) || !this.peers.has(id)) return;
1477
1524
  if (remoteId(id)) this.expireRemoteQueues();
@@ -7,6 +7,8 @@ import { withoutOrcaEnv } from "./orca-env.ts";
7
7
  * lifecycle calls could bypass the machine recovery lock.
8
8
  */
9
9
  export function childEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
10
+ // Keep the caller's environment for person-owned native launch paths; peer and helper
11
+ // children that must not inherit Orca use peerChildEnv or withoutOrcaEnv explicitly.
10
12
  const env = { ...source };
11
13
  delete env.AGENTHUB_RECOVERY_OPERATION;
12
14
  delete env.AGENTHUB_RECOVERY_CONDUCTOR;
@@ -20,15 +22,15 @@ export function childEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.Proces
20
22
  * typed or started there has the console's identity, whatever started the hub.
21
23
  */
22
24
  export function personEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
23
- // The Orca terminal the hub itself was started in is not this new terminal: a launch there must not record it as its own, so every ORCA_ variable goes (#613).
24
25
  const env = withoutOrcaEnv(childEnv(source));
25
26
  for (const name of ["CLAUDECODE", "CLAUDE_CODE_SESSION_ID", "CODEX_THREAD_ID", "AGENTHUB_CHANNEL", "AGENTHUB_PEER_ID", "AGENTHUB_UNATTENDED"]) delete env[name];
26
27
  return env;
27
28
  }
28
29
 
29
30
  /** Every native child owns its marker; caller vendor markers must not leak across peers (#193). */
30
- export function peerChildEnv(peer: string, source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
31
- const env = childEnv(source);
31
+ export function peerChildEnv(peer: string, source: NodeJS.ProcessEnv = process.env, options: { preserveOrca?: true } = {}): NodeJS.ProcessEnv {
32
+ const inherited = childEnv(source);
33
+ const env = options.preserveOrca ? inherited : withoutOrcaEnv(inherited);
32
34
  delete env.CLAUDECODE;
33
35
  delete env.CLAUDE_CODE_SESSION_ID;
34
36
  delete env.CODEX_THREAD_ID;
@@ -60,7 +62,7 @@ export function parseProcessTable(text: string): ProcRow[] {
60
62
 
61
63
  const PS = ["ps", "-axo", "pid=,ppid=,pgid=,stat=,lstart=,command="];
62
64
  // `lstart` is local time: one zone for every reader, or a reader in another zone sees every identity as changed.
63
- const PS_OPTIONS = { env: { ...process.env, LC_ALL: "C", TZ: "UTC" }, detached: true } as const;
65
+ const PS_OPTIONS = { env: { ...withoutOrcaEnv(childEnv(process.env)), LC_ALL: "C", TZ: "UTC" }, detached: true } as const;
64
66
  /** The rows of one read, or undefined when they do not show the reader: a parse that found nothing is not a table. */
65
67
  const own = (text: string) => { const rows = parseProcessTable(text); return rows.some((row) => row.pid === process.pid) ? rows : undefined; };
66
68
 
@@ -27,6 +27,7 @@ import { peerRosterConfig, staleInstanceKey } from "./roster.ts";
27
27
  import { CHARACTER_IDS } from "../pet/characters.ts";
28
28
  import { autonomyOwnership, checkSettingValue, settingDef } from "./settings.ts";
29
29
  import { petPalette } from "../pet/config.ts";
30
+ import { parseSpecsConfig } from "./specs-config.ts";
30
31
 
31
32
  export type ApplyClass = "live" | "next use" | "restart";
32
33
  export interface ApplyRule { applies: ApplyClass; reason?: string }
@@ -202,6 +203,7 @@ const schema: Record<string, Check> = {
202
203
  pii: block({ paths: strings }), data: block({ readonly: strings }),
203
204
  text_projection: v => { projectionConfig(v); },
204
205
  corpus: v => { corpusConfig(v); },
206
+ specs: (v, k) => { block({ enabled: bool, review: words(["off", "report", "require"]), roots: strings, stamps_file: text })(v, k); parseSpecsConfig(v); },
205
207
  conductor: block({ feed: words(["own", "all", "off"]), approval_wait_s: number(0, 2_147_483_647 / 1000) }),
206
208
  budget: block({ gate: number(0, 1), stale_min: timerMinutes, poll_min: timerMinutes, checkpoint_timeout_s: timerSeconds, kimi_tokens_5h: number(0), wait_max_min: number(0) }),
207
209
  context: block({ gate: number(0, 1), stale_min: timerMinutes, max_assignment_used: number(0, 1) }),
@@ -271,7 +273,7 @@ const restart: Record<string, string> = {
271
273
  profile: "Session tool schemas and profile contracts are bound at startup", profile_overrides: "Session tool schemas and profile contracts are bound at startup",
272
274
  text_projection: "Converter and cache ownership are bound at startup", corpus: "The corpus index is bound to its roots at startup", terminal: "Terminal provider is supplied by the daemon lifecycle", bench: "Benchmark execution environment is selected before work starts",
273
275
  };
274
- const live = new Set(["action_policy", "permission", "roster", "roles", "planner", "conductor", "protect_instruction_files", "task_sweep", "capabilities", "invocation", "research", "snapshots", "tasks.wip_limits", "tasks.escalate_after", "tasks.auto_pull", "tasks.queue_backpressure", "schedules", "approvals.notify", "pet"]);
276
+ const live = new Set(["action_policy", "permission", "roster", "roles", "planner", "conductor", "protect_instruction_files", "task_sweep", "capabilities", "invocation", "research", "snapshots", "tasks.wip_limits", "tasks.escalate_after", "tasks.auto_pull", "tasks.queue_backpressure", "schedules", "approvals.notify", "pet", "specs"]);
275
277
  const nextMemory = new Set(["memory.inject_tokens", "memory.brief_items", "memory.suggest_edges"]);
276
278
  /** Closed top-level schema; leaf overrides retain the existing session/runtime boundaries. */
277
279
  export function configApply(key: string, scopes: readonly ConfigIdentityScope[] = []): ApplyRule {
@@ -279,6 +281,7 @@ export function configApply(key: string, scopes: readonly ConfigIdentityScope[]
279
281
  if (!(Object.hasOwn(schema, top) || top === "permission" || top === "routing")) return fail(key, "unknown configuration key");
280
282
  if (top !== "routing" && top !== "permission" && key.includes(".")) {
281
283
  const special: Record<string, readonly string[]> = {
284
+ specs: ["enabled", "review", "roots", "stamps_file"],
282
285
  task_sweep: ["enabled", "auto_reassign", "no_writes_turns", "interval_s", "unaccepted_min", "idle_min", "review_min", "ready_min", "ladder_min"],
283
286
  checks: ["timeout_s", "runner", "remote", ...CLASSES], terminal: ["open"], start: ["agents"],
284
287
  };
@@ -132,7 +132,7 @@ function topLevel(lines: readonly string[]): { end: number; spans: Map<string, [
132
132
  * Edits `mode` and `required_checks` line by line. Every line the edit does not touch keeps its own ending, so a one-key change never
133
133
  * rewrites a CRLF checkout; a line the edit writes or adds takes the ending of the first line break the file has (LF for a new file).
134
134
  */
135
- function editSdlcText(text: string | null, set: ConfigEditSet[]): string | null {
135
+ export function editSdlcText(text: string | null, set: ConfigEditSet[]): string | null {
136
136
  const raw = text === null ? [] : text.split("\n");
137
137
  const trailing = raw.length > 0 && raw.at(-1) === "";
138
138
  if (trailing) raw.pop();
@@ -39,6 +39,7 @@ const isObject = (value: unknown): value is Record<string, unknown> => !!value &
39
39
  export function configTier(file: ConfigEditFile, path: readonly string[], value?: unknown, held?: unknown): ConfigTier {
40
40
  if (!path.length || path.some(part => typeof part !== "string" || !part || part.length > 64 || UNSAFE.has(part) || /[\s\x00-\x1f]/.test(part))) return refuse("invalid configuration path");
41
41
  const key = path.join(".");
42
+ if (file === "config.local.json" && path[0] === "specs" && path.length === 2 && ["enabled", "review", "roots", "stamps_file"].includes(path[1]!)) return { tier: 2 };
42
43
  if (file === "sdlc.toml") return path.length === 1 && (SDLC_KEYS as readonly string[]).includes(path[0]!) ? { tier: 2 } : refuse(`sdlc.${key} is not editable here: only mode and required_checks are; edit .agenthub/sdlc.toml in your editor`);
43
44
  if (registryKey(key)) return { refused: `${key} is already editable with ahub settings set ${key}; nothing was written` };
44
45
  if (path[0] === "research" && path[1] === "runners") {
@@ -1,6 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
3
  import { projectContext } from "./project.ts";
4
+ import { newToolRequestId, TOOL_WRITE_FEATURE, toolWriteOperation, toolWriteUnknownReply, toolWriteRefusedReply } from "./tool-request.ts";
4
5
  import { externalAdapterMetadata, externalInstanceCatalog, type ExternalInstanceCatalog, type ExternalAdapterMetadata } from "../adapters/external-metadata.ts";
5
6
  import { negotiateProtocol, protocolWindow, type ProtocolWindow } from "./protocol-window.ts";
6
7
 
@@ -12,7 +13,8 @@ export function stateDirFor(cwd: string): string {
12
13
  export const PROTOCOL = 17;
13
14
  /** The descriptor's legacy protocol field remains this floor for previous published clients. */
14
15
  export const PROTOCOL_MIN = 16;
15
- export const DAEMON_FEATURES = ["project_autonomy", "conductor_recovery", "encrypted_rooms", "external_adapter_instances", "external_adapter_modules", "configured_acp", "native_hook_launch", "task_cancel", "invocation", "recovery_deferred_hub", "peer_instances", "role_stages", "task_planners", "release_trains", "reviewer_pool", "native_tui_launch", "terminal_owner", "cli_audit_delegated", "action_policy_floor", "calendar_schedules", "research_records", "research_dispatch", "research_dropped_offers", "research_experiments", "research_comparisons", "research_runners", "task_handoff", "session_identity", "schema_guard"] as const;
16
+ // Literal metadata is read without executing a recovery target; consistency is pinned in tests.
17
+ export const DAEMON_FEATURES = ["project_autonomy", "conductor_recovery", "encrypted_rooms", "external_adapter_instances", "external_adapter_modules", "configured_acp", "native_hook_launch", "task_cancel", "invocation", "recovery_deferred_hub", "peer_instances", "role_stages", "task_planners", "release_trains", "reviewer_pool", "native_tui_launch", "terminal_owner", "cli_audit_delegated", "action_policy_floor", "calendar_schedules", "research_records", "research_dispatch", "research_dropped_offers", "research_experiments", "research_comparisons", "research_runners", "task_handoff", "session_identity", "schema_guard", "design_specs", "queue_discard_states", "tool_write_idempotency"] as const;
16
18
  /** Protocols a current coordinator may authenticate while upgrading a running source. */
17
19
  export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, 14, 15, 16, PROTOCOL] as const;
18
20
 
@@ -221,20 +223,42 @@ export class ControlClient {
221
223
  request(msg: Record<string, unknown>, timeoutMs = 30_000, signal?: AbortSignal): Promise<any> {
222
224
  if (this.ws.readyState !== WebSocket.OPEN || this.closing && msg.t !== "request_cancel") return Promise.resolve({ ok: false, error: "hub connection is not open" });
223
225
  const invocation = msg.t === "task" && msg.op === "hub_invoke";
226
+ const operation = toolWriteOperation(msg), write = operation !== undefined;
227
+ const keyedWrite = write && operation !== "hub_delivery_done";
228
+ if (keyedWrite && msg.args && typeof msg.args === "object" && !Array.isArray(msg.args) && Object.hasOwn(msg.args, "request_id")) {
229
+ const { request_id: key, ...args } = msg.args as Record<string, unknown>;
230
+ if (typeof key !== "string" || !key || key.length > 256 || /[\p{Cc}\p{Cf}]/u.test(key)
231
+ || msg.requestId !== undefined && msg.requestId !== key) {
232
+ return Promise.resolve(toolWriteRefusedReply(msg, undefined, "request_id must be a non-empty echoed request key and agree with requestId"));
233
+ }
234
+ msg = { ...msg, args, requestId: key };
235
+ }
236
+ if (keyedWrite && msg.requestId !== undefined && !this.features.includes(TOOL_WRITE_FEATURE)) {
237
+ return Promise.resolve(toolWriteRefusedReply(msg, typeof msg.requestId === "string" ? msg.requestId : undefined,
238
+ "this daemon does not advertise replay protection; nothing was resubmitted"));
239
+ }
240
+ let wire = msg;
241
+ if (keyedWrite && this.features.includes(TOOL_WRITE_FEATURE) && msg.requestId === undefined) {
242
+ try { wire = { ...msg, requestId: newToolRequestId(this.instanceId ?? "") }; }
243
+ catch { return Promise.resolve({ ok: false, error: "write request identity is unavailable; inspect ahub status before retrying" }); }
244
+ }
245
+ const requestId = typeof wire.requestId === "string" ? wire.requestId : undefined;
246
+ const uncertain = (cause: string) => toolWriteUnknownReply(wire, requestId,
247
+ this.features.includes(TOOL_WRITE_FEATURE) ? cause : `${cause}; this daemon does not advertise replay protection`);
224
248
  timeoutMs = controlRequestTimeout(msg, timeoutMs);
225
249
  if (signal?.aborted) return Promise.resolve({ ok: false, error: "hub request cancelled before sending" });
226
250
  const rid = this.nextRid++;
227
251
  return new Promise((resolve) => {
228
252
  let timer: ReturnType<typeof setTimeout> | undefined;
229
253
  const done = (reply: any) => {
230
- this.pending.delete(rid); this.invocations.delete(rid); clearTimeout(timer); signal?.removeEventListener("abort", aborted); resolve(reply);
254
+ this.pending.delete(rid); this.invocations.delete(rid); clearTimeout(timer); signal?.removeEventListener("abort", aborted); resolve(write && reply?.error === "hub connection closed" ? uncertain(reply.error) : reply);
231
255
  };
232
256
  const abandon = (error: string) => {
233
257
  if (!this.pending.has(rid)) return;
234
258
  if (invocation && this.ws.readyState === WebSocket.OPEN) {
235
259
  try { this.ws.send(JSON.stringify({ t: "request_cancel", request: rid })); } catch { /* Socket close also withdraws admission. */ }
236
260
  }
237
- done({ ok: false, error: invocation ? `${error}; invocation admission unconfirmed, inspect before retrying` : error });
261
+ done(write ? uncertain(error) : { ok: false, error: invocation ? `${error}; invocation admission unconfirmed, inspect before retrying` : error });
238
262
  };
239
263
  const aborted = () => abandon("hub request cancelled");
240
264
  this.pending.set(rid, done);
@@ -242,7 +266,7 @@ export class ControlClient {
242
266
  signal?.addEventListener("abort", aborted, { once: true });
243
267
  if (signal?.aborted) { aborted(); return; }
244
268
  timer = timeoutMs ? setTimeout(() => abandon(`no answer from the hub within ${Math.round(timeoutMs / 1000)} s`), timeoutMs) : undefined;
245
- try { this.ws.send(JSON.stringify({ ...msg, rid })); }
269
+ try { this.ws.send(JSON.stringify({ ...wire, rid })); }
246
270
  catch { abandon("hub request could not be sent"); }
247
271
  });
248
272
  }
@@ -6,19 +6,35 @@ export interface CostTask { repo: string; pr: number; private?: boolean; history
6
6
  export interface CostPr { repo: string; pr: number; state: "OPEN" | "CLOSED" | "MERGED"; mergedAt?: number; complete: boolean; ciRuns?: { id: number; attempt: number }[]; reviews?: number }
7
7
  export interface CostRow { repo: string; pr: number; reviewRounds: number; ciRuns: number | null; ciReruns: number | null; fullReReviews: number; deltaReviews: number; reviewMs: number | null; unknownReviewScopes: number; githubReviews: number | null; complete: boolean }
8
8
  export interface CostReport { rows: CostRow[]; truncated: boolean }
9
- export function cleanCostPlan(input: unknown): CostPlan {
10
- if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("invalid cost scope");
9
+ /**
10
+ * #1087: why a cost scope was refused. The message is fixed text that names the field and its allowed values and never the value given, so a call site may show it
11
+ * as it is. Only this class is passed through; any other error keeps the generic refusal.
12
+ */
13
+ export class CostScopeError extends Error {
14
+ constructor(message: string) { super(message); this.name = "CostScopeError"; }
15
+ }
16
+ /**
17
+ * `field` says how the caller's own argument is named, so the refusal points at what the caller actually wrote: `plan` for a task plan (`plan.review.scope`), empty for the
18
+ * plan-review tool's own `review` and `ci` arguments (`review.scope`). One string covers both keys; an object names each key's source separately, for a call that mixes them.
19
+ */
20
+ export function cleanCostPlan(input: unknown, field: string | { review: string; ci: string } = "plan"): CostPlan {
21
+ const at = (name: string) => { const prefix = typeof field === "string" ? field : field[name.split(".")[0] as "review" | "ci"]; return prefix ? `${prefix}.${name}` : name; };
22
+ const refuse = (message: string): never => { throw new CostScopeError(message); };
23
+ if (!input || typeof input !== "object" || Array.isArray(input)) refuse(`${typeof field === "string" && field ? field : "the request"} must be an object; review and ci are its cost scope fields`);
11
24
  const value = input as Record<string, unknown>;
12
25
  const result: CostPlan = {};
13
26
  if (value.review !== undefined) {
14
27
  const review = value.review as Record<string, unknown>;
15
- if (!review || typeof review !== "object" || Array.isArray(review) || Object.keys(review).some(key => !["scope", "reviewers"].includes(key))
16
- || !["none", "delta", "full"].includes(review.scope as string) || !Number.isSafeInteger(review.reviewers)
17
- || (review.scope === "none" ? review.reviewers !== 0 : (review.reviewers as number) < 1 || (review.reviewers as number) > 8)) throw new Error("invalid review cost scope");
28
+ if (!review || typeof review !== "object" || Array.isArray(review)) refuse(`${at("review")} must be an object with scope (none, delta or full) and reviewers (a whole number)`);
29
+ if (Object.keys(review).some(key => !["scope", "reviewers"].includes(key))) refuse(`${at("review")} takes only scope and reviewers`);
30
+ if (!["none", "delta", "full"].includes(review.scope as string)) refuse(`${at("review.scope")} must be none, delta or full`);
31
+ if (!Number.isSafeInteger(review.reviewers)) refuse(`${at("review.reviewers")} must be a whole number: 0 when scope is none, 1 to 8 when scope is delta or full`);
32
+ if (review.scope === "none" && review.reviewers !== 0) refuse(`${at("review.reviewers")} must be 0 when scope is none`);
33
+ if (review.scope !== "none" && ((review.reviewers as number) < 1 || (review.reviewers as number) > 8)) refuse(`${at("review.reviewers")} must be 1 to 8 when scope is delta or full`);
18
34
  result.review = { scope: review.scope as ReviewScope["scope"], reviewers: review.reviewers as number };
19
35
  }
20
36
  if (value.ci !== undefined) {
21
- if (!["focused", "full", "reuse"].includes(value.ci as string)) throw new Error("invalid CI cost scope");
37
+ if (!["focused", "full", "reuse"].includes(value.ci as string)) refuse(`${at("ci")} must be focused, full or reuse`);
22
38
  result.ci = value.ci as CostPlan["ci"];
23
39
  }
24
40
  return result;
package/src/hub/crash.ts CHANGED
@@ -97,3 +97,23 @@ export function lossNotice(lost: JournalDelivery[], taskTitle: (id: number) => s
97
97
  "Check your work against them before you go on.",
98
98
  ].join("\n");
99
99
  }
100
+
101
+ /**
102
+ * One line of the crash-recovery report in `ahub status` (#1082). The report is a list of these, kept for the whole run, and what `ahub status` shows is `openCrashNotices` of it, computed when
103
+ * status is asked, so a line whose condition is gone is gone.
104
+ * - `peer` set: the line asks something of that peer's owner ("run ahub codex again", "reconnects by itself if its client is still running"). It stands while that peer is not attached, and clears
105
+ * when it is, whoever started it.
106
+ * - `inFlight` set: the headline. Its count of deliveries the crash left in needs_review is read live, and the clause disappears at zero.
107
+ * - neither: what recovery found or did ("kimi resumed", "pi not resumed (...)"). It is history, not an open action, and stays.
108
+ */
109
+ export interface CrashNotice { text: string; peer?: string; inFlight?: true }
110
+
111
+ /** The lines `ahub status` shows now: `attached` are the peers that are not offline, `inFlight` is how many of the crash's own deliveries are still in needs_review. */
112
+ export function openCrashNotices(notices: readonly CrashNotice[], live: { attached: ReadonlySet<string>; inFlight: number }): string[] {
113
+ const lines: string[] = [];
114
+ for (const notice of notices) {
115
+ if (notice.peer !== undefined && live.attached.has(notice.peer)) continue;
116
+ lines.push(notice.inFlight ? `${notice.text}${live.inFlight ? `; ${live.inFlight} deliveries it had in flight are in needs_review (ahub queue list)` : ""}` : notice.text);
117
+ }
118
+ return lines;
119
+ }