@staix/agent-hub 0.23.1 → 0.25.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 (95) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +32 -5
  3. package/docs/agent-notes/adapters.md +5 -2
  4. package/docs/agent-notes/bus.md +1 -0
  5. package/docs/agent-notes/daemon.md +16 -2
  6. package/docs/agent-notes/tasks.md +9 -2
  7. package/docs/agent-notes/tests.md +3 -0
  8. package/docs/dogfood.md +16 -2
  9. package/docs/memory.md +1 -1
  10. package/docs/operations.md +198 -5
  11. package/docs/profiles.md +2 -0
  12. package/docs/quickstart.md +19 -4
  13. package/docs/security.md +225 -8
  14. package/docs/specs/2026-09-19-agent-hub-design.md +54 -1
  15. package/docs/specs/2026-09-19-multi-project-design.md +12 -1
  16. package/docs/specs/2026-11-01-research-campaigns-design.md +351 -0
  17. package/docs/verification/2026-10-31-0.24.0.md +63 -0
  18. package/docs/verification/2026-10-31-claude-implementer-2.1.316.md +65 -0
  19. package/docs/verification/2026-11-01-0.25.0.md +91 -0
  20. package/docs/verification/claude-shell-wrapper-native.py +1 -1
  21. package/docs/verified.json +31 -15
  22. package/package.json +4 -3
  23. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  24. package/plugins/agent-hub/server.js +5120 -4710
  25. package/src/adapters/claude-channel.ts +29 -6
  26. package/src/adapters/compat.ts +3 -3
  27. package/src/adapters/definitions/native-cli-claude-setup.ts +4 -0
  28. package/src/adapters/definitions/native-cli-doctor.ts +30 -0
  29. package/src/adapters/jcode.ts +3 -0
  30. package/src/adapters/registry.ts +1 -1
  31. package/src/cli/acon-register.ts +29 -0
  32. package/src/cli/autonomy-homes.ts +33 -0
  33. package/src/cli/autonomy-offline.ts +75 -0
  34. package/src/cli/autonomy-projects.ts +103 -0
  35. package/src/cli/autonomy.ts +44 -0
  36. package/src/cli/claude-homes.ts +123 -0
  37. package/src/cli/claude-profile-hook.ts +130 -0
  38. package/src/cli/handoff-args.ts +27 -0
  39. package/src/cli/handoff-output.ts +25 -0
  40. package/src/cli/help.ts +8 -4
  41. package/src/cli/identity.ts +8 -1
  42. package/src/cli/main.ts +174 -33
  43. package/src/cli/output.ts +8 -5
  44. package/src/cli/schedule-output.ts +8 -4
  45. package/src/cli/setup-homes.ts +46 -0
  46. package/src/cli/setup.ts +1 -1
  47. package/src/cli/study-output.ts +71 -0
  48. package/src/cli/study.ts +58 -0
  49. package/src/cli/task-output.ts +1 -0
  50. package/src/hub/action-class.ts +203 -14
  51. package/src/hub/action-enforce.ts +8 -6
  52. package/src/hub/action-policy.ts +95 -9
  53. package/src/hub/autonomy-consent.ts +173 -0
  54. package/src/hub/autonomy-defaults.ts +58 -0
  55. package/src/hub/autonomy.ts +232 -0
  56. package/src/hub/board.ts +871 -2
  57. package/src/hub/channel-project.ts +61 -0
  58. package/src/hub/config-apply.ts +3 -2
  59. package/src/hub/confirmation-matrix.ts +202 -0
  60. package/src/hub/control-client.ts +10 -1
  61. package/src/hub/daemon.ts +546 -27
  62. package/src/hub/envelope.ts +2 -0
  63. package/src/hub/events.ts +4 -1
  64. package/src/hub/execution-budget.ts +12 -0
  65. package/src/hub/handoff.ts +126 -0
  66. package/src/hub/hub-tools.ts +12 -2
  67. package/src/hub/manager.ts +64 -57
  68. package/src/hub/no-writes.ts +1 -1
  69. package/src/hub/path-policy.ts +12 -8
  70. package/src/hub/project.ts +14 -5
  71. package/src/hub/provenance.ts +30 -0
  72. package/src/hub/research-campaign.ts +265 -0
  73. package/src/hub/research-experiments.ts +445 -0
  74. package/src/hub/restart.ts +26 -0
  75. package/src/hub/schedules.ts +210 -9
  76. package/src/hub/session-identity.ts +49 -0
  77. package/src/hub/settings.ts +105 -8
  78. package/src/hub/task-repository.ts +11 -6
  79. package/src/hub/tasks.ts +894 -22
  80. package/src/hub/ui.ts +15 -6
  81. package/src/memory/capture.ts +2 -2
  82. package/src/ui/index.html +5 -3
  83. package/src/ui/office-space.ts +118 -0
  84. package/src/ui/plugin.ts +293 -0
  85. package/templates/AGENTS.block.md +1 -0
  86. package/templates/presets/autonomous.json +161 -0
  87. package/templates/presets/confirmation-matrix.json +943 -0
  88. package/templates/profiles/general.json +1 -1
  89. package/templates/profiles/research.json +4 -4
  90. package/templates/profiles/writing.json +1 -1
  91. package/templates/roles/research/analyst.md +1 -0
  92. package/templates/roles/research/fact-checker.md +1 -0
  93. package/templates/roles/research/research-lead.md +1 -0
  94. package/templates/roles/research/researcher.md +1 -0
  95. package/templates/roles/research/statistician.md +1 -0
@@ -19,6 +19,8 @@ export interface Envelope {
19
19
  refs?: { repo?: string; branch?: string; commit?: string; paths?: string[]; task?: string; reviewRevision?: string; taskOffer?: number; taskCancelled?: number; taskRecipient?: "owner" | "reviewer" | "planner"; planReview?: number; planReviewFlow?: number; planAuthor?: string; backlog?: number[]; supervision?: boolean; supervisionKey?: string; completedFor?: number; boundedCheck?: string; budgetResume?: string; budgetResumeMoves?: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]; budgetResumeEarlier?: string[]; invocation?: string; invocationRemote?: true };
20
20
  /** The body must not be shown outside its recipients: console tail and log print a stub instead (PII tasks). */
21
21
  private?: boolean;
22
+ /** Sent by a distinct session (`<peer>~<origin>`, agent-hub#1036): a connection that named a peer id but is not that peer's current session. It grants nothing; it marks the sender for the log. */
23
+ session?: true;
22
24
  ts: number;
23
25
  }
24
26
 
package/src/hub/events.ts CHANGED
@@ -22,7 +22,10 @@ export type HubEvent =
22
22
  | { type: "config_edit"; file: "config.local.json" | "sdlc.toml"; paths: { path: string; before: string | null; after: string | null }[]; fileBefore: string; fileAfter: string; tier: 2 | 3; by: "person"; source: "dashboard" | "terminal"; backup?: string; reason?: string }
23
23
  /** #445 C2: an invocation's ids, class and outcome. Never an argument, a result or a description. */
24
24
  | { type: "invocation"; id: string; caller: string; target: string; capability: string; class: string; outcome: string; task?: number; bytes?: number }
25
- | { type: "envelope"; id: string; from: string; to?: string[]; priority: string; hop: number; kind?: string; task?: string; bytes?: number; private?: boolean; dropped?: string }
25
+ /** `origin` (#604 PR B): the sending connection's role, peer and launch or instance id. Never a path or text; absent for an envelope the hub itself wrote. */
26
+ | { type: "envelope"; id: string; from: string; to?: string[]; priority: string; hop: number; kind?: string; task?: string; bytes?: number; private?: boolean; dropped?: string; origin?: import("./provenance.ts").Origin }
27
+ /** #604 PR B: one per peer-role or tools-role hello, with the same fields an envelope's `origin` carries. No cwd, root or token. */
28
+ | ({ type: "attach" } & import("./provenance.ts").Origin)
26
29
  | { type: "overflow" | "undeliverable"; id: string; from: string; peer: string }
27
30
  | { type: "stale"; id: string; from: string; peer: string; task?: string }
28
31
  | { type: "quiet"; id: string; from: string; peers: string[] }
@@ -91,6 +91,18 @@ export class ExecutionBudget {
91
91
  return this.scopes(taskIds, peer).length > 0;
92
92
  }
93
93
 
94
+ /** Capability comes from the resolved instance definition, never the peer's spelling. */
95
+ instrumented(peer: string): boolean { return definitionOf(peer, this.instances)?.budget?.instrumented === true; }
96
+
97
+ /** The named budget must cover this task and owner; another applicable budget proves nothing. */
98
+ covers(id: string, taskId: number, peer: string, resources: readonly ExecutionUnit[]): boolean {
99
+ if (!this.instrumented(peer) || !resources.every(unit => UNITS.has(unit) && unit !== "tokens")) return false;
100
+ const row = this.scopes([taskId], peer).find(row => row.id === id);
101
+ if (!row) return false;
102
+ const limits = fromRow(row).limits;
103
+ return resources.every(unit => limits[unit] !== undefined && !(unit === "model_calls" && limits.tokens !== undefined));
104
+ }
105
+
94
106
  private scopes(taskIds: number[], peer: string): BudgetRow[] {
95
107
  const ids = [...new Set(taskIds.filter((id) => Number.isSafeInteger(id) && id > 0))];
96
108
  const taskRows = ids.length
@@ -0,0 +1,126 @@
1
+ // Cross-project task handoff (#604, agent-hub#1030). Pure: values in, values out, no clock, no file system, no socket. The daemon, the registry and the control client are
2
+ // passed in as data or as small ports, so every refusal here is testable without a second daemon and the policy cannot drift from what the daemon enforces.
3
+ import { CLASSES, type TaskClass } from "./board.ts";
4
+
5
+ /**
6
+ * What crosses projects, and nothing else: no memory, evidence, turn content or task history (#1030 design 5). The per-field limits alone allow about 13 KB, so `bodyBytes` is the real
7
+ * ceiling on the whole request: a body that fills several fields is refused whole, and no partial task is built from it.
8
+ */
9
+ export const HANDOFF_LIMITS = Object.freeze({ title: 300, detail: 4000, paths: 16, path: 512, issue: 2_000_000_000, bodyBytes: 8 * 1024 });
10
+ export const HANDOFF_FIELDS = Object.freeze(["project", "title", "class", "detail", "paths", "issue", "task"] as const);
11
+
12
+ export type HandoffRefusalCode =
13
+ | "invalid" | "too_large" | "unregistered" | "same_project" | "not_running" | "older_target" | "pii_turn" | "pii_task" | "no_authority";
14
+
15
+ /** A refusal always names its reason, and for the person-fixable ones the action. The code is for tests and logs, the message for the caller. */
16
+ export class HandoffRefusal extends Error {
17
+ constructor(readonly code: HandoffRefusalCode, message: string) { super(message); this.name = "HandoffRefusal"; }
18
+ }
19
+
20
+ /** `task` is the caller's OWN task this handoff comes from, so a `[pii]` one is refused and the target can name it in the follow-back. It never crosses as text. */
21
+ export interface HandoffRequest { title: string; class: TaskClass; detail: string; paths: string[]; issue?: number; task?: number }
22
+
23
+ const hasControl = (text: string): boolean => /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(text);
24
+
25
+ /**
26
+ * Callers are models, and MCP schemas are not enforced on the way in: parse closed. An unknown field, an accessor or a non-plain object is refused, never dropped,
27
+ * because a dropped field is a request the caller thinks it made. The result is sized, so an oversized body is refused whole and no partial task is built from it.
28
+ */
29
+ export function parseHandoff(input: unknown): HandoffRequest & { project: string } {
30
+ if (!input || typeof input !== "object" || Array.isArray(input) || ![Object.prototype, null].includes(Object.getPrototypeOf(input)) || Object.getOwnPropertySymbols(input).length)
31
+ throw new HandoffRefusal("invalid", "handoff must be a plain data object");
32
+ const descriptors = Object.getOwnPropertyDescriptors(input);
33
+ for (const [key, descriptor] of Object.entries(descriptors)) {
34
+ if (!(HANDOFF_FIELDS as readonly string[]).includes(key)) throw new HandoffRefusal("invalid", `handoff has an unknown field: ${key.slice(0, 40)}`);
35
+ if (!Object.hasOwn(descriptor, "value")) throw new HandoffRefusal("invalid", `handoff field ${key} is an accessor`);
36
+ }
37
+ const field = (name: (typeof HANDOFF_FIELDS)[number]): unknown => descriptors[name]?.value;
38
+ const text = (value: unknown, name: string, max: number, required: boolean): string => {
39
+ if (value === undefined || value === null || value === "") { if (required) throw new HandoffRefusal("invalid", `handoff ${name} is required`); return ""; }
40
+ if (typeof value !== "string") throw new HandoffRefusal("invalid", `handoff ${name} must be text`);
41
+ if (hasControl(value)) throw new HandoffRefusal("invalid", `handoff ${name} has control characters`);
42
+ if (Buffer.byteLength(value, "utf8") > max * 4) throw new HandoffRefusal("too_large", `handoff ${name} is over ${max} characters`);
43
+ if ([...value].length > max) throw new HandoffRefusal("too_large", `handoff ${name} is over ${max} characters`);
44
+ return value;
45
+ };
46
+ const project = text(field("project"), "project", HANDOFF_LIMITS.path, true).trim();
47
+ if (!project) throw new HandoffRefusal("invalid", "handoff project is required");
48
+ const title = text(field("title"), "title", HANDOFF_LIMITS.title, true).replace(/\s+/g, " ").trim();
49
+ if (!title) throw new HandoffRefusal("invalid", "handoff title is required");
50
+ const detail = text(field("detail"), "detail", HANDOFF_LIMITS.detail, false);
51
+ const cls = field("class") === undefined || field("class") === null || field("class") === "" ? "implement" : field("class");
52
+ if (typeof cls !== "string" || !(CLASSES as readonly string[]).includes(cls)) throw new HandoffRefusal("invalid", `handoff class must be one of ${CLASSES.join(", ")}`);
53
+ const rawPaths = field("paths");
54
+ if (rawPaths !== undefined && rawPaths !== null && (!Array.isArray(rawPaths) || Object.keys(rawPaths).length !== rawPaths.length)) throw new HandoffRefusal("invalid", "handoff paths must be a dense list of text");
55
+ if (Array.isArray(rawPaths) && rawPaths.length > HANDOFF_LIMITS.paths) throw new HandoffRefusal("too_large", `handoff has more than ${HANDOFF_LIMITS.paths} paths`);
56
+ const paths = (Array.isArray(rawPaths) ? rawPaths : []).map(path => {
57
+ const value = text(path, "path", HANDOFF_LIMITS.path, true).trim();
58
+ // Paths are the target's, repository-relative: an absolute path or a parent step names a place on this machine, not a file in the target's tree.
59
+ if (!value || value.startsWith("/") || value.startsWith("~") || /^[A-Za-z]:[\\/]/.test(value) || value.split(/[\\/]/).includes("..")) throw new HandoffRefusal("invalid", "handoff paths are repository-relative: no absolute path, ~ or ..");
60
+ return value;
61
+ });
62
+ const rawIssue = field("issue");
63
+ let issue: number | undefined;
64
+ if (rawIssue !== undefined && rawIssue !== null && rawIssue !== "") {
65
+ issue = typeof rawIssue === "string" && /^[1-9]\d{0,9}$/.test(rawIssue) ? Number(rawIssue) : rawIssue as number;
66
+ if (typeof issue !== "number" || !Number.isSafeInteger(issue) || issue < 1 || issue > HANDOFF_LIMITS.issue) throw new HandoffRefusal("invalid", "handoff issue must be a positive issue number in the target's repository");
67
+ }
68
+ const rawTask = field("task");
69
+ let task: number | undefined;
70
+ if (rawTask !== undefined && rawTask !== null && rawTask !== "") {
71
+ task = typeof rawTask === "string" && /^[1-9]\d{0,9}$/.test(rawTask) ? Number(rawTask) : rawTask as number;
72
+ if (typeof task !== "number" || !Number.isSafeInteger(task) || task < 1 || task > HANDOFF_LIMITS.issue) throw new HandoffRefusal("invalid", "handoff task must be a positive task id of this project");
73
+ }
74
+ const request = { project, title, class: cls as TaskClass, detail, paths, ...(issue === undefined ? {} : { issue }), ...(task === undefined ? {} : { task }) };
75
+ if (Buffer.byteLength(JSON.stringify(request), "utf8") > HANDOFF_LIMITS.bodyBytes) throw new HandoffRefusal("too_large", `handoff is over ${HANDOFF_LIMITS.bodyBytes} bytes`);
76
+ return request;
77
+ }
78
+
79
+ export interface RegisteredProject { id: string; root: string; stateDir: string }
80
+ export interface TargetDescriptor { features?: readonly string[]; instanceId?: string; projectId?: string; cwd?: string }
81
+
82
+ /**
83
+ * The target is named by canonical root or id and must be registered. The caller's own project and an unregistered name are refused here, before anything is read from a
84
+ * state directory. `canonical` resolves a typed path the way the registry does (`realPath`, never `realpathSync`), so a symlinked temporary root matches its registration.
85
+ */
86
+ export function resolveTarget(name: string, projects: readonly RegisteredProject[], self: { id: string; root: string }, canonical: (path: string) => string): RegisteredProject {
87
+ const typed = name.trim();
88
+ let resolved: string | undefined;
89
+ try { resolved = typed.startsWith("/") || typed.startsWith("~") || typed.startsWith(".") ? canonical(typed) : undefined; } catch { resolved = undefined; }
90
+ const match = projects.find(project => project.id === typed || (resolved !== undefined && project.root === resolved));
91
+ if (!match) throw new HandoffRefusal("unregistered", "that project is not in this machine's agent-hub registry; run `ahub projects` to see the registered ones, or `ahub up` in that project once");
92
+ if (match.id === self.id || match.root === self.root) throw new HandoffRefusal("same_project", "that is this project: a task for this project goes through hub_task_propose");
93
+ return match;
94
+ }
95
+
96
+ /** What the target's own status descriptor says. A daemon that is not running, or does not know the handoff, is refused with the person's action. */
97
+ export function checkTarget(project: RegisteredProject, descriptor: TargetDescriptor | undefined): void {
98
+ if (!descriptor) throw new HandoffRefusal("not_running", `the hub of ${project.id} is not running; run \`ahub up\` in ${project.root}, then hand the task off again (handoffs are not queued)`);
99
+ if (descriptor.projectId !== undefined && descriptor.projectId !== project.id) throw new HandoffRefusal("not_running", `the hub answering for ${project.root} belongs to another project; run \`ahub up\` in ${project.root}`);
100
+ if (!descriptor.features?.includes("task_handoff")) throw new HandoffRefusal("older_target", `the hub of ${project.id} is too old to take a handoff (no task_handoff feature): upgrade the target hub with \`ahub upgrade\` in ${project.root}`);
101
+ }
102
+
103
+ /** The states a target reports back, in the order a task can reach them. `declined` is the owner passing; the task then returns to the target's routing, so it is information, not an end. */
104
+ export const HANDOFF_EVENTS = Object.freeze(["accepted", "in_review", "approved", "declined", "cancelled"] as const);
105
+ export type HandoffEvent = (typeof HANDOFF_EVENTS)[number];
106
+
107
+ /** Map a target's task history entry to the event the origin is told about, or undefined for every other entry. Reads only the event name and the task state, never a note. */
108
+ export function handoffEventFor(entryEvent: string, state: string): HandoffEvent | undefined {
109
+ // The names are what `Tasks` writes (observed: accept writes "accepted", a done leaves the task in_review, the verdict writes "approved", a decline "declined", a cancel "cancelled").
110
+ if (entryEvent === "accepted" && state === "in_progress") return "accepted";
111
+ if (entryEvent === "declined") return "declined";
112
+ if (entryEvent === "cancelled" && state === "cancelled") return "cancelled";
113
+ if (entryEvent === "done" && state === "in_review") return "in_review";
114
+ if (entryEvent === "approved" && state === "approved") return "approved";
115
+ // A class with no reviewer: the done itself approves (`in_progress -> approved`), so the origin hears "approved" for that entry, not "in review".
116
+ if (entryEvent === "done" && state === "approved") return "approved";
117
+ return undefined;
118
+ }
119
+
120
+ const SHA = /^[0-9a-f]{40}$/;
121
+ /** One line, no free text from the target beyond a PR number and a head: `agent-hub task 123 approved: PR #7 at <sha>`. */
122
+ export function followBackLine(remoteTask: number, event: HandoffEvent, evidence: { pr?: unknown; head?: unknown } = {}): string {
123
+ const pr = typeof evidence.pr === "number" && Number.isSafeInteger(evidence.pr) && evidence.pr > 0 ? ` PR #${evidence.pr}` : "";
124
+ const head = typeof evidence.head === "string" && SHA.test(evidence.head) ? ` at ${evidence.head}` : "";
125
+ return `agent-hub task ${remoteTask} ${event.replace("_", " ")}${pr || head ? `:${pr}${head}` : ""}`;
126
+ }
@@ -8,7 +8,7 @@ import { NOTE_KINDS } from "./envelope.ts";
8
8
 
9
9
  /** Restricted to the fixed current-child tools server, never an ordinary peer tool. */
10
10
  /** The fixed MCP surface of a current owned implementer child; independent of broader project roles. */
11
- export const IMPLEMENTER_HUB_TOOL_NAMES: readonly string[] = Object.freeze(["hub_task_accept", "hub_task_decline", "hub_task_done", "hub_checkpoint", "hub_send", "hub_task_show", "hub_task_propose", "hub_remember", "hub_inbox", "hub_permission_request"]);
11
+ export const IMPLEMENTER_HUB_TOOL_NAMES: readonly string[] = Object.freeze(["hub_task_accept", "hub_task_decline", "hub_task_done", "hub_checkpoint", "hub_send", "hub_task_show", "hub_task_propose", "hub_remember", "hub_inbox", "hub_permission_request", "hub_study_show", "hub_study_handoff"]);
12
12
 
13
13
  export const IMPLEMENTER_PERMISSION_TOOL = {
14
14
  name: "hub_permission_request",
@@ -81,6 +81,10 @@ const tool = (name: string, description: string, properties: Record<string, unkn
81
81
  inputSchema: { type: "object", properties, required, additionalProperties: false },
82
82
  });
83
83
 
84
+ /** #601: a campaign's contract and handoff are model-written text about its sources. The write tools' arguments and every study result stay out of memory capture. */
85
+ export const STUDY_TOOLS = new Set(["hub_study_draft", "hub_study_revise", "hub_study_show", "hub_study_handoff"]);
86
+ /** #604: a handoff's arguments cross to another project, so neither tool's arguments nor result are captured to memory. */
87
+ export const HANDOFF_TOOLS = new Set(["hub_task_handoff", "hub_task_handoffs"]);
84
88
  export const BOUNDED_TASK_TOOLS = new Set(["hub_task_spec", "hub_check_start", "hub_check_result", "hub_check_cancel"]);
85
89
  /** #445 C1: what a peer lists and publishes. Their text is another agent's words, so neither call is captured to memory. */
86
90
  export const CAPABILITY_TOOLS = new Set(["hub_capabilities", "hub_capabilities_publish", "hub_invoke"]);
@@ -96,6 +100,12 @@ export const TASK_TOOLS: HubTool[] = [
96
100
  tool("hub_backlog_pull", "Reserve a WIP slot and pull ready unpinned backlog work into To Do at its current revision. Preserves person order/reservations and frozen scope; pending unowned work can be undone with hub_backlog_undo.", { id, revision: { type: "integer", minimum: 0 }, reason: { type: "string", minLength: 1, maxLength: 1000 } }, ["id", "revision", "reason"]),
97
101
  tool("hub_backlog_split", "Split unstarted backlog work into two to eight staged parts. The original card retains integration/acceptance and depends on the children. Use after for existing prerequisite ids and afterParts for earlier zero-based part indexes. Latest unused unchanged children can be undone with hub_backlog_undo.", { id, revision: { type: "integer", minimum: 0 }, reason: { type: "string", minLength: 1, maxLength: 1000 }, parts: { type: "array", minItems: 2, maxItems: 8, items: { type: "object", properties: { title: { type: "string", minLength: 1, maxLength: 300 }, detail: { type: "string", maxLength: 8000 }, class: str, refs, plan, after: { type: "array", maxItems: 64, uniqueItems: true, items: { type: "integer", minimum: 1 } }, afterParts: { type: "array", maxItems: 8, uniqueItems: true, items: { type: "integer", minimum: 0 } } }, required: ["title"], additionalProperties: false } } }, ["id", "revision", "reason", "parts"]),
98
102
  tool("hub_backlog_undo", "Undo your latest planning change at its current revision. Refuses intervening changes and another actor's edit.", { id, revision: { type: "integer", minimum: 1 } }, ["id", "revision"]),
103
+ tool("hub_study_draft", "Store a research campaign contract as a DRAFT revision 1 on an approved parent task. Configured planners only; it creates no task, dispatches nothing and never approves (only the person approves, with ahub study approve). The contract is data: question, scope, exclusions, sourceIds, units, deliverableSections, stopPolicy and an optional budget.", { parentTaskId: id, contract: { type: "object" } }, ["parentTaskId", "contract"]),
104
+ tool("hub_study_revise", "Store the next DRAFT revision of a research campaign with the revision you read. Configured planners only; a stale revision refuses, and a draft holds older bound work until the person approves it. Never approves.", { id: str, revision: { type: "integer", minimum: 1 }, contract: { type: "object" } }, ["id", "revision", "contract"]),
105
+ tool("hub_study_show", "Read a research campaign's coverage and stop projection: per unit round, state, handoff completeness and budget support, and why dispatch is held. Read-only. A private campaign, task or source shows ids, counts and states only, never its question, scope, path or excerpt. Give no id to list this project's campaigns, at most 50 per call: when the reply carries `next`, pass it as `after` for the following page.", { id: str, after: str }),
106
+ tool("hub_task_handoff", "Hand work that belongs to ANOTHER project that runs agent-hub to that project's board. Use it instead of implementing it from this session: a feature or behavior change first needs a spec issue in that project (pass its number as issue); a mechanical chore (an exact version pin, a regenerated file) goes straight to a task. The target is a registered project (its root or id) whose hub is running; it is not queued. Planner, conductor or person only. Never for a [pii] task or during a private turn. You learn the task's state changes as kind task messages.", { project: str, title: str, class: { type: "string", description: "task class, default implement" }, detail: str, paths: { type: "array", items: str, description: "repository-relative paths in the TARGET project" }, issue: { type: "integer", minimum: 1, description: "the target repository's issue number" }, task: { type: "integer", minimum: 1, description: "your own task in THIS project that this work comes from; a [pii] task is refused" } }, ["project", "title"]),
107
+ tool("hub_task_handoffs", "List what this project handed to other projects, with the last state each target reported. Read-only.", {}),
108
+ tool("hub_study_handoff", "Record the handoff for the research unit you currently own and have in progress: evidence per source, unresolved questions, contradictions and limitations, or insufficientEvidence. Only the bound owner; every required field must be present. Not a task completion.", { handoff: { type: "object" } }, ["handoff"]),
99
109
  tool("hub_release_new", "Create a future release planning row. Planner/person only; no activation, publish or release authorization.", { name: { type: "string", minLength: 1, maxLength: 64 }, goal: { type: "string", minLength: 1, maxLength: 1000 }, order: { type: "integer", minimum: 0 } }, ["name", "goal", "order"]),
100
110
  tool("hub_release_report", "Read a planned release's task readiness and recorded metrics. Planner/person only. A due date never waives unfinished scope.", { id }, ["id"]),
101
111
  tool("hub_release_assign", "Assign or move only a task's release, retaining its execution state and owner. Planner/person only; requires a reason and current planning revision. Frozen scope refuses.", { id, revision: { type: "integer", minimum: 0 }, release: { type: ["integer", "null"], minimum: 1 }, reason: { type: "string", minLength: 1, maxLength: 1000 } }, ["id", "revision", "release", "reason"]),
@@ -184,7 +194,7 @@ export function configuredToolContext(status: unknown, peer: string): ProjectToo
184
194
  return { startPeers, readOnly: readOnlyPeers.includes(peer) };
185
195
  }
186
196
 
187
- const READ_ONLY_PROJECT_TOOLS = Object.freeze(["hub_capabilities", "hub_send", "hub_inbox", "hub_delivery_done", "hub_review", "hub_task_show", "hub_task_list", "hub_memory_search", "hub_memory_show", "hub_memory_graph", "hub_source_read", "hub_status"]);
197
+ const READ_ONLY_PROJECT_TOOLS = Object.freeze(["hub_capabilities", "hub_send", "hub_inbox", "hub_delivery_done", "hub_review", "hub_task_show", "hub_task_list", "hub_study_show", "hub_memory_search", "hub_memory_show", "hub_memory_graph", "hub_source_read", "hub_status"]);
188
198
  /** Publication restricts exposure only; server-side authority remains mandatory for every call. */
189
199
  export function projectToolList<T extends { name: string }>(tools: T[], readOnly = false): T[] {
190
200
  return readOnly ? tools.filter(tool => READ_ONLY_PROJECT_TOOLS.includes(tool.name)) : tools;
@@ -79,6 +79,69 @@ function removeManifest(home: string, instanceId: string) {
79
79
  rmSync(files(home).token, { force: true });
80
80
  }
81
81
 
82
+ /** The manager's project listing and forwarding (#935): shared by `ahub ui --all` and the acon plugin, never widened for either. */
83
+ export function projectConsole(options: { registry: Registry; lifecycle: Lifecycle; home: string; stopping?: () => boolean; forwardTimeoutMs?: ManagerOptions["forwardTimeoutMs"] }) {
84
+ const { registry, lifecycle, home } = options;
85
+ const aliases = new Map<string, { at: number; names: string[] }>();
86
+ const list = async () => {
87
+ const projects = registry.list();
88
+ for (const p of projects) if (!aliases.has(p.id) || Date.now() - aliases.get(p.id)!.at > 60_000) {
89
+ aliases.set(p.id, { at: Date.now(), names: existsSync(p.root) ? projectChain(p.root) : [] });
90
+ }
91
+ const rows = await Promise.all(projects.map(async (project) => {
92
+ let result: ProjectInspection;
93
+ try { result = await bounded(lifecycle.inspectProject(project), 7000); }
94
+ catch (error) { result = { state: "unavailable", error: safeError(error) }; }
95
+ const names = aliases.get(project.id)!.names;
96
+ const shared = projects.some((other) => other.id !== project.id && aliases.get(other.id)!.names.some((name) => names.includes(name)));
97
+ return { id: project.id, root: project.root, state: result.state,
98
+ instanceId: result.state === "running" ? result.status?.instanceId ?? null : null,
99
+ peers: result.status?.peers ?? {}, tasks: result.status?.tasks ?? {},
100
+ ...(shared ? { warning: "Native memory aliases are shared with another registered project." } : {}),
101
+ ...(result.error ? { error: result.error } : {}) };
102
+ }));
103
+ return { ok: true, mode: "all", projects: rows };
104
+ };
105
+ const act = async (input: Record<string, unknown>) => {
106
+ if (options.stopping?.()) return { ok: false, error: "manager is stopping" };
107
+ const project = typeof input.projectId === "string" ? registry.get(input.projectId) : undefined;
108
+ if (!project) return { ok: false, error: "unknown project" };
109
+ let client: ControlClient | undefined;
110
+ try {
111
+ if (input.action === "start") {
112
+ const status = await bounded(lifecycle.startProject(project, { unattended: false, env: { ...scrubEnv(process.env), AGENTHUB_HOME: home } }), 20_000);
113
+ return { ok: true, projectId: project.id, instanceId: status.instanceId };
114
+ }
115
+ if (typeof input.instanceId !== "string" || !input.instanceId) return { ok: false, error: "expected daemon instance is required" };
116
+ if (input.action === "stop") {
117
+ await bounded(lifecycle.stopProject(project, input.instanceId), 20_000);
118
+ return { ok: true };
119
+ }
120
+ // The settings actions (#269) reach the project hub as an ordinary session's: this dashboard opens no settings session.
121
+ if (!["snapshot", "send", "pause", "resume", "permit", "propose", "assign", "backlog_pull", "backlog_return", "planning", "planning_undo", "planner_pull", "backlog_split", "release_new", "release_change", "setting", "setting_undo", "setting_preview", "setting_reload", "config_edit_preview", "config_edit_apply", "start_peer", "sdlc_queue", "sdlc_release"].includes(String(input.action))) return { ok: false, error: "invalid dashboard action" };
122
+ client = await ControlClient.connect(project.stateDir, { role: "console", projectId: project.id, projectRoot: project.root, instanceId: input.instanceId });
123
+ const deliveryRead = input.action === "sdlc_queue" || input.action === "sdlc_release";
124
+ let nominalMs = 10_000;
125
+ if (input.action === "start_peer") nominalMs = MAX_COMMAND_MS + 5_000;
126
+ else if (input.action === "backlog_split") nominalMs = BACKLOG_SPLIT_SCREEN_TIMEOUT_MS + HUB_DB_BUSY_TIMEOUT_MS + 5_000;
127
+ else if (deliveryRead) nominalMs = 30_000;
128
+ const timeoutMs = options.forwardTimeoutMs?.(String(input.action), nominalMs) ?? nominalMs;
129
+ const result = await client.request(input.action === "snapshot"
130
+ ? { t: "ui_snapshot", after: input.after ?? 0, ...(input.memory !== undefined ? { memory: input.memory } : {}), ...(dashboardView(input.view) ? { view: input.view } : {}) }
131
+ : { t: "ui_action", action: input, instanceId: input.instanceId }, timeoutMs);
132
+ // ControlClient resolves its deadline as this exact sentinel; a hub refusal stays a known failure.
133
+ if (["start_peer", "backlog_split"].includes(String(input.action)) && result.ok === false && result.error === `no answer from the hub within ${Math.round(timeoutMs / 1000)} s`) {
134
+ const text = input.action === "backlog_split" ? "Split result is unconfirmed. Reload the task before retrying; the hub may still commit its children." : "Start result is unconfirmed. Check the Peers panel before retrying; the hub may still be starting the peer.";
135
+ return { ok: false, unconfirmed: true, text, error: text };
136
+ }
137
+ if (deliveryRead) { const { rid: _transportId, ...report } = result; return report; }
138
+ return result;
139
+ } catch (error) { return { ok: false, error: safeError(error) }; }
140
+ finally { client?.close(); }
141
+ };
142
+ return { list, act };
143
+ }
144
+
82
145
  export async function startManager(options: ManagerOptions = {}): Promise<ManagerHandle> {
83
146
  const home = options.home ?? hubHome();
84
147
  const existing = active.get(home);
@@ -119,63 +182,7 @@ export async function startManager(options: ManagerOptions = {}): Promise<Manage
119
182
  };
120
183
  try {
121
184
  owner = claimOwner(f.owner, instanceId, pidSignature);
122
- const aliases = new Map<string, { at: number; names: string[] }>();
123
- const list = async () => {
124
- const projects = registry.list();
125
- for (const p of projects) if (!aliases.has(p.id) || Date.now() - aliases.get(p.id)!.at > 60_000) {
126
- aliases.set(p.id, { at: Date.now(), names: existsSync(p.root) ? projectChain(p.root) : [] });
127
- }
128
- const rows = await Promise.all(projects.map(async (project) => {
129
- let result: ProjectInspection;
130
- try { result = await bounded(lifecycle.inspectProject(project), 7000); }
131
- catch (error) { result = { state: "unavailable", error: safeError(error) }; }
132
- const names = aliases.get(project.id)!.names;
133
- const shared = projects.some((other) => other.id !== project.id && aliases.get(other.id)!.names.some((name) => names.includes(name)));
134
- return { id: project.id, root: project.root, state: result.state,
135
- instanceId: result.state === "running" ? result.status?.instanceId ?? null : null,
136
- peers: result.status?.peers ?? {}, tasks: result.status?.tasks ?? {},
137
- ...(shared ? { warning: "Native memory aliases are shared with another registered project." } : {}),
138
- ...(result.error ? { error: result.error } : {}) };
139
- }));
140
- return { ok: true, mode: "all", projects: rows };
141
- };
142
- const act = async (input: Record<string, unknown>) => {
143
- if (stopping) return { ok: false, error: "manager is stopping" };
144
- const project = typeof input.projectId === "string" ? registry.get(input.projectId) : undefined;
145
- if (!project) return { ok: false, error: "unknown project" };
146
- let client: ControlClient | undefined;
147
- try {
148
- if (input.action === "start") {
149
- const status = await bounded(lifecycle.startProject(project, { unattended: false, env: { ...scrubEnv(process.env), AGENTHUB_HOME: home } }), 20_000);
150
- return { ok: true, projectId: project.id, instanceId: status.instanceId };
151
- }
152
- if (typeof input.instanceId !== "string" || !input.instanceId) return { ok: false, error: "expected daemon instance is required" };
153
- if (input.action === "stop") {
154
- await bounded(lifecycle.stopProject(project, input.instanceId), 20_000);
155
- return { ok: true };
156
- }
157
- // The settings actions (#269) reach the project hub as an ordinary session's: this dashboard opens no settings session.
158
- if (!["snapshot", "send", "pause", "resume", "permit", "propose", "assign", "backlog_pull", "backlog_return", "planning", "planning_undo", "planner_pull", "backlog_split", "release_new", "release_change", "setting", "setting_undo", "setting_preview", "setting_reload", "config_edit_preview", "config_edit_apply", "start_peer", "sdlc_queue", "sdlc_release"].includes(String(input.action))) return { ok: false, error: "invalid dashboard action" };
159
- client = await ControlClient.connect(project.stateDir, { role: "console", projectId: project.id, projectRoot: project.root, instanceId: input.instanceId });
160
- const deliveryRead = input.action === "sdlc_queue" || input.action === "sdlc_release";
161
- let nominalMs = 10_000;
162
- if (input.action === "start_peer") nominalMs = MAX_COMMAND_MS + 5_000;
163
- else if (input.action === "backlog_split") nominalMs = BACKLOG_SPLIT_SCREEN_TIMEOUT_MS + HUB_DB_BUSY_TIMEOUT_MS + 5_000;
164
- else if (deliveryRead) nominalMs = 30_000;
165
- const timeoutMs = options.forwardTimeoutMs?.(String(input.action), nominalMs) ?? nominalMs;
166
- const result = await client.request(input.action === "snapshot"
167
- ? { t: "ui_snapshot", after: input.after ?? 0, ...(input.memory !== undefined ? { memory: input.memory } : {}), ...(dashboardView(input.view) ? { view: input.view } : {}) }
168
- : { t: "ui_action", action: input, instanceId: input.instanceId }, timeoutMs);
169
- // ControlClient resolves its deadline as this exact sentinel; a hub refusal stays a known failure.
170
- if (["start_peer", "backlog_split"].includes(String(input.action)) && result.ok === false && result.error === `no answer from the hub within ${Math.round(timeoutMs / 1000)} s`) {
171
- const text = input.action === "backlog_split" ? "Split result is unconfirmed. Reload the task before retrying; the hub may still commit its children." : "Start result is unconfirmed. Check the Peers panel before retrying; the hub may still be starting the peer.";
172
- return { ok: false, unconfirmed: true, text, error: text };
173
- }
174
- if (deliveryRead) { const { rid: _transportId, ...report } = result; return report; }
175
- return result;
176
- } catch (error) { return { ok: false, error: safeError(error) }; }
177
- finally { client?.close(); }
178
- };
185
+ const { list, act } = projectConsole({ registry, lifecycle, home, stopping: () => stopping, forwardTimeoutMs: options.forwardTimeoutMs });
179
186
  dashboard = startDashboard({ projects: list, snapshot: (after, input = {}) => act({ ...input, action: "snapshot", after }), action: act });
180
187
  server = Bun.serve({ hostname: "127.0.0.1", port: 0, maxRequestBodySize: 1024,
181
188
  async fetch(req) {
@@ -37,7 +37,7 @@ export class NoWriteTurns {
37
37
  }
38
38
 
39
39
 
40
- const OWNER_TOOL_EFFECTS = new Set(["hub_task_propose", "hub_task_decline", "hub_task_done", "hub_task_cancel", "hub_task_assign", "hub_task_escalate", "hub_review", "hub_checkpoint", "hub_remember", "hub_memory_correct", "hub_check_start", "hub_check_cancel", "hub_sdlc_record", "hub_plan_review", "hub_backlog_plan", "hub_backlog_pull", "hub_backlog_undo", "hub_backlog_split", "hub_backlog_return_untouched", "hub_release_new", "hub_release_configure", "hub_release_assign", "hub_release_cut"]);
40
+ const OWNER_TOOL_EFFECTS = new Set(["hub_task_propose", "hub_task_decline", "hub_task_done", "hub_task_cancel", "hub_task_assign", "hub_task_escalate", "hub_review", "hub_checkpoint", "hub_remember", "hub_memory_correct", "hub_check_start", "hub_check_cancel", "hub_sdlc_record", "hub_plan_review", "hub_backlog_plan", "hub_backlog_pull", "hub_backlog_undo", "hub_backlog_split", "hub_backlog_return_untouched", "hub_release_new", "hub_release_configure", "hub_release_assign", "hub_release_cut", "hub_study_handoff"]);
41
41
  /** A read does not prove progress; failed/pending mutations are handled conservatively by the host. */
42
42
  export function ownerToolEffect(op: string, input: Record<string, unknown> | undefined): boolean {
43
43
  return OWNER_TOOL_EFFECTS.has(op) || op === "hub_task_accept" && !!input?.plan && typeof input.plan === "object" && Object.keys(input.plan).length > 0;
@@ -32,7 +32,11 @@ export function policyTargets(cwd: string, paths: string[]): string[] {
32
32
  const root = realPath(cwd);
33
33
  return [...new Set(paths.flatMap(path => { const lexical = resolve(root, path); return [lexical, policyRealPath(lexical)]; }))];
34
34
  }
35
- type DiscoveryBudget = { entries: number };
35
+ type DiscoveryBudget = { entries: number; failClosedOnDiscoveryLimit?: boolean };
36
+ function discoveryLimit(budget: DiscoveryBudget): false {
37
+ if (budget.failClosedOnDiscoveryLimit) throw new Error("policy alias discovery incomplete");
38
+ return false;
39
+ }
36
40
  /** A dangling direct child names a future lexical target; an ELOOP child opens no file. */
37
41
  function directAliasTarget(path: string): string | undefined {
38
42
  let target = path;
@@ -55,16 +59,16 @@ function directAliasTarget(path: string): string | undefined {
55
59
  }
56
60
  return undefined;
57
61
  }
58
- /** Only a completed bounded scan contributes aliases; a truncated root contributes none. */
62
+ /** Only completed bounded scans contribute aliases; opt-in strict callers treat an incomplete scan as protected. */
59
63
  function aliasScope(dir: string, lexical: string, actual: string, includeParents: boolean, budget: DiscoveryBudget): boolean {
60
64
  // Readability must not depend on reaching readSync after an exhausted-budget return.
61
65
  accessSync(dir, constants.R_OK);
62
66
  const handle = opendirSync(dir);
63
67
  let matched = false;
64
68
  try {
65
- if (budget.entries >= 4096) return false;
69
+ if (budget.entries >= 4096) return discoveryLimit(budget);
66
70
  for (let entry = handle.readSync(); entry; entry = handle.readSync()) {
67
- if (budget.entries >= 4096) return false;
71
+ if (budget.entries >= 4096) return discoveryLimit(budget);
68
72
  budget.entries++;
69
73
  if (!entry.isSymbolicLink()) continue;
70
74
  const target = directAliasTarget(join(dir, entry.name));
@@ -138,13 +142,13 @@ function globScope(root: string, lexical: string, actual: string, path: string,
138
142
  if (!lowerBeneath(real, root)) throw new Error("policy glob discovery outside project");
139
143
  const info = statSync(dir);
140
144
  if (!info.isDirectory()) return false;
141
- if (aliasDiscovery && budget.entries >= 4096) return false;
145
+ if (aliasDiscovery && budget.entries >= 4096) return discoveryLimit(budget);
142
146
  if (depth > 32 || ancestors.has(real) || budget.entries > 4096) throw new Error("policy glob discovery unavailable");
143
147
  const chain = new Set(ancestors); chain.add(real);
144
148
  const handle = opendirSync(dir);
145
149
  try {
146
150
  for (let entry = handle.readSync(); entry; entry = handle.readSync()) {
147
- if (aliasDiscovery && budget.entries >= 4096) return false;
151
+ if (aliasDiscovery && budget.entries >= 4096) return discoveryLimit(budget);
148
152
  if (++budget.entries > 4096) throw new Error("policy glob discovery unavailable");
149
153
  const next = advance(pattern, states, entry.name);
150
154
  if (next.size && visit(join(dir, entry.name), next, depth + 1, chain)) return true;
@@ -163,13 +167,13 @@ export function matchesPolicyLexically(cwd: string, path: string, paths: string[
163
167
  }
164
168
 
165
169
  /** Compare declared spelling, real targets and bounded aliases; legacy discovery errors fail closed. */
166
- export function matchesPolicy(cwd: string, path: string, paths: string[], includeParents = false): boolean {
170
+ export function matchesPolicy(cwd: string, path: string, paths: string[], includeParents = false, options: { failClosedOnDiscoveryLimit?: boolean } = {}): boolean {
167
171
  if (!paths.length) return false;
168
172
  try {
169
173
  if (path.length > 4096) return true;
170
174
  const root = realPath(cwd), lexical = resolve(root, path), actual = policyRealPath(lexical);
171
175
  includeParents &&= !existing(lexical) || statSync(lexical).isDirectory();
172
- const budget = { entries: 0 }, roots = new Set<string>(), exact: string[] = [];
176
+ const budget: DiscoveryBudget = { entries: 0, failClosedOnDiscoveryLimit: options.failClosedOnDiscoveryLimit }, roots = new Set<string>(), exact: string[] = [];
173
177
  if (paths.some(entry => {
174
178
  if (/[?*]/.test(entry)) return globScope(root, lexical, actual, entry, includeParents, budget, roots);
175
179
  const targets = policyTargets(root, [entry]);
@@ -66,15 +66,24 @@ export function projectContext(cwd: string, env: NodeJS.ProcessEnv = process.env
66
66
  const root = projectRoot(cwd);
67
67
  const override = env.AGENTHUB_STATE_DIR?.trim();
68
68
  if (!override) return { root, stateDir: canonical(join(root, ".agenthub", "state")) };
69
+ const inherited = inheritedStateDir(root, env);
70
+ return { root, stateDir: inherited ?? canonical(join(root, ".agenthub", "state")) };
71
+ }
69
72
 
73
+ /**
74
+ * The ownership proof for an inherited state directory, on its own so the channel plugin applies the same rule as the CLI (#604 PR B, item 10). The directory is accepted only when the root
75
+ * recorded in it equals `root`, or when it records none (a freshly initialized state) and the inherited `AGENTHUB_PROJECT_DIR` names `root`. A marker for another project is authoritative:
76
+ * an inherited pair from the main checkout must not pull a process started in a linked worktree into the main repository's hub.
77
+ */
78
+ export function inheritedStateDir(root: string, env: NodeJS.ProcessEnv = process.env): string | undefined {
79
+ const override = env.AGENTHUB_STATE_DIR?.trim();
80
+ if (!override) return undefined;
70
81
  const declared = env.AGENTHUB_PROJECT_DIR?.trim();
71
82
  const stateDir = canonical(override);
72
83
  const recorded = stateMarker(stateDir);
73
- if (recorded === root) return { root, stateDir };
74
- // A marker for another project is authoritative; an inherited mismatch must not
75
- // redirect this cwd. The explicit pair is only for a freshly initialized state.
76
- if (!recorded && declared && canonical(declared) === root) return { root, stateDir };
77
- return { root, stateDir: canonical(join(root, ".agenthub", "state")) };
84
+ if (recorded === root) return stateDir;
85
+ if (!recorded && declared && canonical(declared) === root) return stateDir;
86
+ return undefined;
78
87
  }
79
88
 
80
89
  export function hubHome(env: NodeJS.ProcessEnv = process.env): string {
@@ -0,0 +1,30 @@
1
+ // Sender provenance (#604 PR B, agent-hub#1030 item 11). Pure: a connection's identity fields in, a bounded record out. The record names the sending connection by role, bound peer
2
+ // and launch or instance id, and nothing else: never a path, a cwd, a project root, a message body or a token. Two sessions that both claim `claude` stay distinguishable in
3
+ // events.jsonl because their launch ids differ, and an envelope can be traced to the connection that sent it.
4
+ /** `instanceId` is a declared extra instance's id when the connection is one. It is never the hub run's own id, which is the same for every connection and so distinguishes none. */
5
+ export interface ConnectionIdentity { role?: "peer" | "console" | "tools"; peer?: string; launchId?: string; instanceId?: string; session?: boolean }
6
+ /** `session: true` marks a distinct session: its `peer` is the daemon-minted `<peer>~<origin>` id (agent-hub#1036). */
7
+ export type Origin = { role: "peer" | "console" | "tools" | "hub"; peer?: string; launch?: string; instance?: string; session?: true };
8
+
9
+ /** An id the hub itself minted (a UUID or a short token). Anything else, or anything long, is dropped rather than copied: the field is evidence, not a channel for caller text. */
10
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
11
+ const idOf = (value: unknown): string | undefined => typeof value === "string" && ID.test(value) ? value : undefined;
12
+ const PEER = /^[a-z][a-z0-9_~-]{0,63}$/;
13
+
14
+ /** `undefined` identity is the hub's own writer (a task flow, a notice): it is `hub`, not a connection. */
15
+ export function originOf(identity: ConnectionIdentity | undefined): Origin {
16
+ if (!identity?.role) return { role: "hub" };
17
+ const peer = typeof identity.peer === "string" && PEER.test(identity.peer) ? identity.peer : undefined;
18
+ const launch = idOf(identity.launchId), instance = idOf(identity.instanceId);
19
+ return { role: identity.role, ...(peer ? { peer } : {}), ...(launch ? { launch } : {}), ...(instance ? { instance } : {}), ...(identity.session === true && peer ? { session: true as const } : {}) };
20
+ }
21
+
22
+ /** The one `attach` event a peer or tools hello writes, and the shape an envelope event embeds as `origin`. */
23
+ export function attachEvent(identity: ConnectionIdentity): { type: "attach" } & Origin {
24
+ return { type: "attach", ...originOf(identity) };
25
+ }
26
+
27
+ /** Whether two origins name the same connection identity: the same role, peer and launch. A connection with no launch id never equals one that has it. */
28
+ export function sameOrigin(a: Origin, b: Origin): boolean {
29
+ return a.role === b.role && a.peer === b.peer && a.launch === b.launch && a.instance === b.instance && a.session === b.session;
30
+ }