@staix/agent-hub 0.24.0 → 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 (70) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +27 -5
  3. package/docs/agent-notes/adapters.md +1 -0
  4. package/docs/agent-notes/bus.md +1 -0
  5. package/docs/agent-notes/daemon.md +15 -2
  6. package/docs/agent-notes/tasks.md +9 -2
  7. package/docs/agent-notes/tests.md +2 -1
  8. package/docs/dogfood.md +12 -2
  9. package/docs/memory.md +1 -1
  10. package/docs/operations.md +137 -4
  11. package/docs/profiles.md +2 -0
  12. package/docs/quickstart.md +10 -3
  13. package/docs/security.md +155 -3
  14. package/docs/specs/2026-09-19-agent-hub-design.md +25 -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-11-01-0.25.0.md +91 -0
  18. package/docs/verified.json +31 -15
  19. package/package.json +4 -3
  20. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  21. package/plugins/agent-hub/server.js +5118 -4708
  22. package/src/adapters/claude-channel.ts +29 -6
  23. package/src/adapters/definitions/native-cli-doctor.ts +30 -0
  24. package/src/adapters/jcode.ts +3 -0
  25. package/src/adapters/registry.ts +1 -1
  26. package/src/cli/acon-register.ts +29 -0
  27. package/src/cli/handoff-args.ts +27 -0
  28. package/src/cli/handoff-output.ts +25 -0
  29. package/src/cli/help.ts +6 -3
  30. package/src/cli/identity.ts +7 -1
  31. package/src/cli/main.ts +76 -7
  32. package/src/cli/output.ts +8 -5
  33. package/src/cli/schedule-output.ts +8 -4
  34. package/src/cli/study-output.ts +71 -0
  35. package/src/cli/study.ts +58 -0
  36. package/src/cli/task-output.ts +1 -0
  37. package/src/hub/board.ts +871 -2
  38. package/src/hub/channel-project.ts +61 -0
  39. package/src/hub/control-client.ts +10 -1
  40. package/src/hub/daemon.ts +247 -11
  41. package/src/hub/envelope.ts +2 -0
  42. package/src/hub/events.ts +4 -1
  43. package/src/hub/execution-budget.ts +12 -0
  44. package/src/hub/handoff.ts +126 -0
  45. package/src/hub/hub-tools.ts +12 -2
  46. package/src/hub/manager.ts +64 -57
  47. package/src/hub/no-writes.ts +1 -1
  48. package/src/hub/path-policy.ts +12 -8
  49. package/src/hub/project.ts +14 -5
  50. package/src/hub/provenance.ts +30 -0
  51. package/src/hub/research-campaign.ts +265 -0
  52. package/src/hub/research-experiments.ts +445 -0
  53. package/src/hub/restart.ts +26 -0
  54. package/src/hub/schedules.ts +210 -9
  55. package/src/hub/session-identity.ts +49 -0
  56. package/src/hub/tasks.ts +894 -22
  57. package/src/hub/ui.ts +15 -6
  58. package/src/memory/capture.ts +2 -2
  59. package/src/ui/index.html +5 -3
  60. package/src/ui/office-space.ts +118 -0
  61. package/src/ui/plugin.ts +293 -0
  62. package/templates/AGENTS.block.md +1 -0
  63. package/templates/profiles/general.json +1 -1
  64. package/templates/profiles/research.json +4 -4
  65. package/templates/profiles/writing.json +1 -1
  66. package/templates/roles/research/analyst.md +1 -0
  67. package/templates/roles/research/fact-checker.md +1 -0
  68. package/templates/roles/research/research-lead.md +1 -0
  69. package/templates/roles/research/researcher.md +1 -0
  70. package/templates/roles/research/statistician.md +1 -0
@@ -15,7 +15,12 @@ export const SCHEDULE_MARKER = "Schedule key: ";
15
15
 
16
16
  export interface ScheduleEntry {
17
17
  id: string;
18
- every: string;
18
+ /** Exactly one of `every` and `cron` (#1017). */
19
+ every?: string;
20
+ /** The five cron fields, single-spaced (`parseCron` checked them). */
21
+ cron?: string;
22
+ /** An IANA zone for `cron`; without it the machine's own zone (`scheduleZone`). */
23
+ tz?: string;
19
24
  class: string;
20
25
  title: string;
21
26
  detail?: string;
@@ -26,7 +31,7 @@ export interface ScheduleEntry {
26
31
 
27
32
  /** The source id form of `src/hub/sources.ts` (`validSourceId`), repeated here because that module reaches the file system and the converters and this one is bundled into the Claude channel server through the config schema; `test/schedules.test.ts` pins that the two agree. */
28
33
  export const SOURCE_ID = /^src:[0-9a-f]{16}$/;
29
- const ID = /^[a-z0-9][a-z0-9_-]{0,47}$/, PEER = /^[a-z][a-z0-9-]{0,31}$/, CLASS = /^[a-z][a-z0-9_]{0,47}$/, KEYS = new Set(["id", "every", "cron", "class", "title", "detail", "sources", "owner", "queue"]);
34
+ const ID = /^[a-z0-9][a-z0-9_-]{0,47}$/, PEER = /^[a-z][a-z0-9-]{0,31}$/, CLASS = /^[a-z][a-z0-9_]{0,47}$/, KEYS = new Set(["id", "every", "cron", "tz", "class", "title", "detail", "sources", "owner", "queue"]);
30
35
  const control = /[\x00-\x1f\x7f]/;
31
36
 
32
37
  /** `<n>m|h|d` to milliseconds; a thrown reason otherwise. The 15-minute floor keeps a typo from flooding the board. */
@@ -39,6 +44,185 @@ export function parseEvery(value: unknown): number {
39
44
  return ms;
40
45
  }
41
46
 
47
+ // agent-hub #1017: calendar-anchored schedules. Spec: "Calendar-anchored schedules (#1017)". Pure: a cron expression and a zone in, instants out, on `Intl` and `Date` only (zero runtime
48
+ // dependencies). Every function takes the time it works from, so a test pins a fixed clock.
49
+
50
+ /** The five fields, each as the sorted values it allows. `domStar` and `dowStar`: the field began with `*`, which the day rule below reads. */
51
+ export interface CronSpec { minutes: number[]; hours: number[]; doms: number[]; months: number[]; dows: number[]; domStar: boolean; dowStar: boolean }
52
+ const CRON_FIELDS = [{ name: "minute", min: 0, max: 59 }, { name: "hour", min: 0, max: 23 }, { name: "day of month", min: 1, max: 31 }, { name: "month", min: 1, max: 12 }, { name: "day of week", min: 0, max: 7 }] as const;
53
+ export const MAX_CRON_LENGTH = 100;
54
+ /**
55
+ * One Gregorian cycle: the calendar, and so every day-of-month, month and weekday pattern, repeats exactly every 146097 days (400 years, a whole number of weeks). Scanning that far makes
56
+ * both questions complete: an expression that never fires has no matching day in it, and one that fires at all fires inside it from any start (a Feb 29 that is a Sunday recurs
57
+ * 28 to 40 years apart). The cost is only paid by a sparse expression: a common one stops at its first match.
58
+ */
59
+ const CRON_HORIZON_DAYS = 146_097;
60
+ const DAY_MS = 86_400_000, MIN_MS = 60_000;
61
+
62
+ function cronField(text: string, name: string, min: number, max: number): number[] {
63
+ const values = new Set<number>();
64
+ for (const part of text.split(",")) {
65
+ const match = /^(?:(\*)|(\d{1,2})(?:-(\d{1,2}))?)(?:\/(\d{1,2}))?$/.exec(part);
66
+ if (!match || (match[2] !== undefined && match[3] === undefined && match[4] !== undefined)) throw new Error(`cron ${name} must be numbers, ranges (a-b), lists (a,b) and steps (*/n or a-b/n)`);
67
+ const from = match[1] ? min : Number(match[2]), to = match[1] ? max : match[3] !== undefined ? Number(match[3]) : from, step = match[4] === undefined ? 1 : Number(match[4]);
68
+ if (from < min || to > max || from > to) throw new Error(`cron ${name} is ${min} to ${max}, and a range runs upward`);
69
+ if (step < 1) throw new Error(`cron ${name} step must be at least 1`);
70
+ for (let value = from; value <= to; value += step) values.add(name === "day of week" && value === 7 ? 0 : value);
71
+ }
72
+ return [...values].sort((a, b) => a - b);
73
+ }
74
+
75
+ /**
76
+ * The standard five fields (minute, hour, day of month, month, day of week): numbers, lists, ranges and steps; day of week 0 and 7 are Sunday. No seconds, no year, no `@` macro, no names.
77
+ * A day matches when both day fields are restricted and EITHER matches, or when one of them begins with `*` and BOTH match (the classic cron rule); month always has to match.
78
+ */
79
+ export function parseCron(expression: unknown): CronSpec {
80
+ if (typeof expression !== "string" || !expression.trim()) throw new Error("cron is five fields: minute hour day-of-month month day-of-week");
81
+ if (expression.length > MAX_CRON_LENGTH || /[^\x20-\x7e]/.test(expression)) throw new Error(`cron is at most ${MAX_CRON_LENGTH} printable characters`);
82
+ if (expression.trim().startsWith("@")) throw new Error("cron takes the five fields, not an @ macro");
83
+ const fields = expression.trim().split(/\s+/);
84
+ if (fields.length !== 5) throw new Error(`cron is exactly five fields (minute hour day-of-month month day-of-week), not ${fields.length}; seconds and a year are not supported`);
85
+ const [minutes, hours, doms, months, dows] = CRON_FIELDS.map((field, index) => cronField(fields[index]!, field.name, field.min, field.max)) as [number[], number[], number[], number[], number[]];
86
+ return { minutes, hours, doms, months, dows, domStar: fields[2]!.startsWith("*"), dowStar: fields[4]!.startsWith("*") };
87
+ }
88
+ /** The expression as stored: the same five fields, single-spaced. */
89
+ export const cronText = (expression: string): string => expression.trim().split(/\s+/).join(" ");
90
+
91
+ function dayMatches(spec: CronSpec, dayMs: number): boolean {
92
+ const date = new Date(dayMs);
93
+ if (!spec.months.includes(date.getUTCMonth() + 1)) return false;
94
+ const dom = spec.doms.includes(date.getUTCDate()), dow = spec.dows.includes(date.getUTCDay());
95
+ return spec.domStar || spec.dowStar ? dom && dow : dom || dow;
96
+ }
97
+
98
+ /**
99
+ * The smallest gap, in minutes, between two fires of the expression on the calendar itself (a wall clock with no zone), or undefined when no day of a full 400-year Gregorian cycle matches (`0 0 31 2 *`).
100
+ * Daylight saving can still move two fires closer on its one change day (a skipped time runs at the first valid minute); a schedule proposes at most one task per pass and none while its
101
+ * previous task is open, so that costs no flood.
102
+ */
103
+ export function cronMinGapMinutes(spec: CronSpec): number | undefined {
104
+ const times = spec.hours.flatMap(hour => spec.minutes.map(minute => hour * 60 + minute));
105
+ let gap = Infinity;
106
+ for (let i = 1; i < times.length; i++) gap = Math.min(gap, times[i]! - times[i - 1]!);
107
+ const start = Date.UTC(2024, 0, 1);
108
+ let any = false, consecutive = false, before = false;
109
+ for (let day = 0; day < CRON_HORIZON_DAYS; day++) {
110
+ const now = dayMatches(spec, start + day * DAY_MS);
111
+ any ||= now;
112
+ if (now && before) consecutive = true;
113
+ before = now;
114
+ }
115
+ if (!any) return undefined;
116
+ return Math.min(gap, consecutive ? 1440 - times[times.length - 1]! + times[0]! : Infinity);
117
+ }
118
+
119
+ const zoneFormats = new Map<string, Intl.DateTimeFormat>();
120
+ function zoneFormat(zone: string): Intl.DateTimeFormat {
121
+ let format = zoneFormats.get(zone);
122
+ if (!format) {
123
+ format = new Intl.DateTimeFormat("en-US", { timeZone: zone, hourCycle: "h23", year: "numeric", month: "numeric", day: "numeric", hour: "numeric", minute: "numeric", second: "numeric" });
124
+ if (zoneFormats.size >= 64) zoneFormats.clear();
125
+ zoneFormats.set(zone, format);
126
+ }
127
+ return format;
128
+ }
129
+ /** An IANA zone name the runtime knows, as it spells it (`utc` is `UTC`), or a thrown reason. */
130
+ export function checkZone(value: unknown): string {
131
+ if (typeof value !== "string" || value.length > 64 || !/^[A-Za-z][A-Za-z0-9_+-]*(?:\/[A-Za-z0-9_+-]+)*$/.test(value)) throw new Error("tz is an IANA zone name such as Asia/Seoul or America/New_York");
132
+ try { return zoneFormat(value).resolvedOptions().timeZone; } catch { throw new Error("tz is not a zone this machine knows; use an IANA name such as Asia/Seoul or America/New_York"); }
133
+ }
134
+ /** The zone a schedule's `cron` is read in: its `tz`, else this machine's own. */
135
+ export function scheduleZone(entry: Pick<ScheduleEntry, "tz">): string {
136
+ if (entry.tz) return entry.tz;
137
+ try { return new Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } catch { return "UTC"; }
138
+ }
139
+
140
+ /** The wall clock of an instant in a zone, as milliseconds on a zone-less calendar (the calendar read as UTC). */
141
+ function wallOf(zone: string, instant: number): number {
142
+ const parts: Record<string, number> = {};
143
+ for (const part of zoneFormat(zone).formatToParts(instant)) if (part.type !== "literal") parts[part.type] = Number(part.value);
144
+ return Date.UTC(parts.year!, parts.month! - 1, parts.day!, parts.hour! % 24, parts.minute!, parts.second!);
145
+ }
146
+ /**
147
+ * The instant a wall-clock minute happens in a zone. A time that happens twice (the clock went back) is its first occurrence, so a repeated time runs once; a time that never happens
148
+ * (the clock jumped forward) is the first minute that does, so a skipped time still runs.
149
+ */
150
+ function instantOf(zone: string, wall: number): number {
151
+ const offset = (instant: number) => wallOf(zone, instant) - Math.floor(instant / 1000) * 1000;
152
+ const tried = new Set<number>(), found: number[] = [];
153
+ for (const guess of [offset(wall - DAY_MS), offset(wall + DAY_MS), offset(wall)]) {
154
+ if (tried.has(guess)) continue;
155
+ tried.add(guess);
156
+ if (wallOf(zone, wall - guess) === wall) found.push(wall - guess);
157
+ }
158
+ if (found.length) return Math.min(...found);
159
+ // A gap: the first minute whose wall clock has reached the skipped time. No zone's offset moves by 30 hours, so this window holds the one change.
160
+ let low = Math.floor((wall - 30 * 3_600_000) / MIN_MS), high = Math.ceil((wall + 30 * 3_600_000) / MIN_MS);
161
+ while (high - low > 1) {
162
+ const middle = Math.floor((low + high) / 2);
163
+ if (wallOf(zone, middle * MIN_MS) >= wall) high = middle; else low = middle;
164
+ }
165
+ return high * MIN_MS;
166
+ }
167
+
168
+ /** The first wall-clock minute strictly after `wall` that the expression allows, or undefined when no day of a full cycle matches. */
169
+ function nextWall(spec: CronSpec, wall: number): number | undefined {
170
+ const from = wall - (wall % MIN_MS) + MIN_MS, firstDay = from - (from % DAY_MS), floor = (from - firstDay) / MIN_MS;
171
+ for (let day = 0; day < CRON_HORIZON_DAYS; day++) {
172
+ const dayMs = firstDay + day * DAY_MS;
173
+ if (!dayMatches(spec, dayMs)) continue;
174
+ for (const hour of spec.hours) for (const minute of spec.minutes) if (hour * 60 + minute >= (day === 0 ? floor : 0)) return dayMs + (hour * 60 + minute) * MIN_MS;
175
+ }
176
+ return undefined;
177
+ }
178
+
179
+ /**
180
+ * The next fire strictly after `after` (epoch ms), or undefined when none exists (no day of a full 400-year cycle matches). Fires are found on the zone's wall clock and then placed on the timeline by
181
+ * `instantOf`: a repeated wall time fires once, and a fire the clock skipped fires at the first valid minute.
182
+ */
183
+ export function cronNext(spec: CronSpec, zone: string, after: number): number | undefined {
184
+ let wall = wallOf(zone, after);
185
+ wall -= wall % MIN_MS;
186
+ for (let guard = 0; guard < 2000; guard++) {
187
+ const next = nextWall(spec, wall);
188
+ if (next === undefined) return undefined;
189
+ const instant = instantOf(zone, next);
190
+ if (instant > after) return instant;
191
+ wall = next;
192
+ }
193
+ return undefined;
194
+ }
195
+
196
+ /** `YYYY-MM-DD HH:mm` in a zone: how a person reads the next run of a calendar schedule. */
197
+ export function formatInZone(instant: number, zone: string): string {
198
+ const iso = new Date(wallOf(zone, instant)).toISOString();
199
+ return `${iso.slice(0, 10)} ${iso.slice(11, 16)}`;
200
+ }
201
+
202
+ /** How a view's period reads: `every 6h`, or `cron 30 7 * * 1-5 (Asia/Seoul)`. */
203
+ export const schedulePeriodText = (view: { every?: string; cron?: string; zone?: string }): string => view.cron !== undefined ? `cron ${view.cron}${view.zone ? ` (${view.zone})` : ""}` : `every ${view.every ?? "?"}`;
204
+ /** An instant as a person reads a schedule's next run: in the zone of a `cron` (`2026-11-02 07:30 Asia/Seoul`), else UTC like every other time in the CLI. */
205
+ export function scheduleWhen(view: { zone?: string }, instant: number): string {
206
+ if (view.zone) { try { return `${formatInZone(instant, view.zone)} ${view.zone}`; } catch { /* an unknown zone falls through to UTC */ } }
207
+ return `${new Date(instant).toISOString().replace("T", " ").slice(0, 16)} UTC`;
208
+ }
209
+
210
+ const parsedCrons = new Map<string, CronSpec>();
211
+ function cronSpec(expression: string): CronSpec {
212
+ let spec = parsedCrons.get(expression);
213
+ if (!spec) {
214
+ spec = parseCron(expression);
215
+ if (parsedCrons.size >= 256) parsedCrons.clear();
216
+ parsedCrons.set(expression, spec);
217
+ }
218
+ return spec;
219
+ }
220
+ /** The next time a schedule is due, given the start of its period: `every` after the anchor, or the next calendar fire of its `cron` after it. Far in the future when a cron has none. */
221
+ export function scheduleNext(entry: Pick<ScheduleEntry, "every" | "cron" | "tz">, anchor: number): number {
222
+ if (entry.cron !== undefined) return cronNext(cronSpec(entry.cron), scheduleZone(entry), anchor) ?? Number.MAX_SAFE_INTEGER;
223
+ return anchor + parseEvery(entry.every);
224
+ }
225
+
42
226
  /** The checked `schedules` list. Every refusal names the entry; nothing here reads the board, a clock or a file. */
43
227
  export function schedulesConfig(input: unknown): ScheduleEntry[] {
44
228
  if (input === undefined) return [];
@@ -54,8 +238,21 @@ export function schedulesConfig(input: unknown): ScheduleEntry[] {
54
238
  if (typeof row.id !== "string" || !ID.test(row.id)) bad("id is 1 to 48 lower-case letters, digits, - or _");
55
239
  if (seen.has(row.id as string)) bad("duplicate id");
56
240
  seen.add(row.id as string);
57
- if (row.cron !== undefined) bad("cron not supported yet; use every");
58
- try { parseEvery(row.every); } catch (error) { bad((error as Error).message); }
241
+ if (row.every !== undefined && row.cron !== undefined) bad("give every or cron, not both");
242
+ if (row.every === undefined && row.cron === undefined) bad("every must be <n>m, <n>h or <n>d, or give cron (five fields)");
243
+ if (row.tz !== undefined && row.cron === undefined) bad("tz applies to cron only");
244
+ let cron: string | undefined, tz: string | undefined;
245
+ if (row.cron !== undefined) {
246
+ try {
247
+ const spec = parseCron(row.cron), gap = cronMinGapMinutes(spec);
248
+ if (gap === undefined) bad("cron never fires: no calendar day matches it");
249
+ if (gap! < MIN_EVERY_MS / MIN_MS) bad(`cron can fire every ${gap} minute${gap === 1 ? "" : "s"}; the minimum is ${MIN_EVERY_MS / MIN_MS} minutes`);
250
+ cron = cronText(row.cron as string);
251
+ if (row.tz !== undefined) tz = checkZone(row.tz);
252
+ } catch (error) { bad((error as Error).message.replace(/^schedules[.[][^:]*: /, "")); }
253
+ } else {
254
+ try { parseEvery(row.every); } catch (error) { bad((error as Error).message); }
255
+ }
59
256
  if (typeof row.class !== "string" || !CLASS.test(row.class)) bad("class is required");
60
257
  if (typeof row.title !== "string" || !row.title.trim() || row.title.length > 300 || control.test(row.title)) bad("title is 1 to 300 printable characters on one line");
61
258
  if (row.detail !== undefined && (typeof row.detail !== "string" || row.detail.length > MAX_SCHEDULE_DETAIL || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(row.detail))) bad(`detail is text of at most ${MAX_SCHEDULE_DETAIL} characters`);
@@ -65,7 +262,7 @@ export function schedulesConfig(input: unknown): ScheduleEntry[] {
65
262
  if (!Array.isArray(row.sources) || row.sources.length > MAX_SCHEDULE_SOURCES || !row.sources.every(id => typeof id === "string" && SOURCE_ID.test(id))) bad(`sources is a list of at most ${MAX_SCHEDULE_SOURCES} source ids (src:<16 hex>)`);
66
263
  }
67
264
  out.push({
68
- id: row.id as string, every: row.every as string, class: row.class as string, title: (row.title as string).replace(/\s+/g, " ").trim(),
265
+ id: row.id as string, ...(cron !== undefined ? { cron, ...(tz !== undefined ? { tz } : {}) } : { every: row.every as string }), class: row.class as string, title: (row.title as string).replace(/\s+/g, " ").trim(),
69
266
  ...(row.detail !== undefined ? { detail: row.detail as string } : {}), ...(row.sources !== undefined ? { sources: [...(row.sources as string[])] } : {}),
70
267
  ...(row.owner !== undefined ? { owner: row.owner as string } : {}), queue: (row.queue as "backlog" | "ready" | undefined) ?? "backlog",
71
268
  });
@@ -229,7 +426,10 @@ export interface ScheduleRunResult { id: string; outcome: ScheduleOutcome; task?
229
426
  export type ScheduleState = "scheduled" | "due" | "waiting" | "held" | "paused";
230
427
  export interface ScheduleView {
231
428
  id: string;
232
- every: string;
429
+ /** `every` or `cron`, never both; `zone` is the zone a `cron` is read in (its `tz`, else the machine's), so a person reads the next run with it (#1017). */
430
+ every?: string;
431
+ cron?: string;
432
+ zone?: string;
233
433
  class: string;
234
434
  queue: "backlog" | "ready";
235
435
  next: number;
@@ -247,10 +447,10 @@ export interface ScheduleView {
247
447
  export function scheduleViews(entries: readonly ScheduleEntry[], rows: readonly ScheduleRow[], now: number, open: ReadonlyMap<string, number>, held: boolean): ScheduleView[] {
248
448
  const byId = new Map(rows.map(row => [row.id, row]));
249
449
  return entries.map(entry => {
250
- const row = byId.get(entry.id), anchor = row?.anchor ?? now, next = anchor + parseEvery(entry.every), openTask = open.get(entry.id);
450
+ const row = byId.get(entry.id), anchor = row?.anchor ?? now, next = scheduleNext(entry, anchor), openTask = open.get(entry.id);
251
451
  const state: ScheduleState = row?.paused ? "paused" : now < next ? "scheduled" : held ? "held" : openTask !== undefined ? "waiting" : "due";
252
452
  return {
253
- id: entry.id, every: entry.every, class: entry.class, queue: entry.queue, next, state,
453
+ id: entry.id, ...(entry.cron !== undefined ? { cron: entry.cron, zone: scheduleZone(entry) } : { every: entry.every }), class: entry.class, queue: entry.queue, next, state,
254
454
  ...(row?.last_run != null ? { last: row.last_run } : {}), ...(openTask !== undefined ? { task: openTask } : row?.last_task != null ? { task: row.last_task } : {}),
255
455
  ...(row?.coalesced ? { coalesced: row.coalesced } : {}), ...(row?.last_error ? { error: row.last_error } : {}),
256
456
  };
@@ -259,7 +459,8 @@ export function scheduleViews(entries: readonly ScheduleEntry[], rows: readonly
259
459
 
260
460
  /** The detail of a scheduled task: the marker line first, then the person's text and the sources it names, all fixed words around config text. */
261
461
  export function scheduledDetail(entry: ScheduleEntry): string {
262
- return [`${SCHEDULE_MARKER}${entry.id}`, `Scheduled every ${entry.every} by the machine-local schedule "${entry.id}".`, ...(entry.detail ? ["", entry.detail] : []),
462
+ const when = entry.cron !== undefined ? `on the calendar "${entry.cron}" (${scheduleZone(entry)})` : `every ${entry.every}`;
463
+ return [`${SCHEDULE_MARKER}${entry.id}`, `Scheduled ${when} by the machine-local schedule "${entry.id}".`, ...(entry.detail ? ["", entry.detail] : []),
263
464
  ...(entry.sources?.length ? ["", ...entry.sources.map(id => `Source: ${id}`)] : [])].join("\n");
264
465
  }
265
466
 
@@ -0,0 +1,49 @@
1
+ // Distinguishable sessions, not authentication (agent-hub#1036, board #606). A connection that names a peer id but is not that id's current session is bound to an id the DAEMON mints,
2
+ // `<peer>~<origin>` (for example `claude~agent-console`), instead of becoming that peer. The id has no `@`, so it never reaches the remote-room paths (`isRemotePeerId`,
3
+ // `membershipAllowed`, `remoteEnvelope`), and `PEER_ID` is not widened: only the daemon makes these ids, and it keeps them in its own Set.
4
+ //
5
+ // What such a session may do: send, and read through the tools below. It is never a WsPeer, never receives a delivery or a push, and holds none of the peer's board roles.
6
+ //
7
+ // Limit, stated plainly: this makes sessions distinguishable and traceable. It does not authenticate them. A same-user process can always act as the user; the point is that a mix-up
8
+ // between two sessions of one person is visible in the header a recipient reads and in events.jsonl.
9
+
10
+ /** The task ops a distinct session may run: reads that need no role. Everything else is refused with `sessionRefusal`. */
11
+ export const SESSION_READ_OPS: ReadonlySet<string> = new Set([
12
+ "hub_task_show", "hub_task_list", "hub_status", "hub_capabilities", "hub_study_show", "hub_backlog_list", "hub_release_list",
13
+ ]);
14
+
15
+ /** The control messages a distinct session may send besides task ops. `task` is gated again by `SESSION_READ_OPS`. */
16
+ export const SESSION_MESSAGES: ReadonlySet<string> = new Set(["send", "status", "task"]);
17
+
18
+ const LABEL = /^[a-z][a-z0-9-]{0,23}$/;
19
+
20
+ /**
21
+ * A label is the other project's name or a short session id, and it ends up in a header another agent reads. It is kept only when it already IS a plain lowercase token: nothing is cleaned up into
22
+ * one, so a path, a sentence or an instruction cannot leave a fragment of itself in a sender name. Anything else is dropped, and the hub mints a random short id instead.
23
+ */
24
+ export function sessionLabel(value: unknown): string | undefined {
25
+ return typeof value === "string" && LABEL.test(value) && !value.endsWith("-") ? value : undefined;
26
+ }
27
+
28
+ /** `taken(id)` says whether a live session already has the id. A second session from the same origin gets a short suffix, so no two live sessions share an id. */
29
+ export function mintSessionId(peer: string, label: unknown, taken: (id: string) => boolean, random: () => string): string {
30
+ const base = `${peer.slice(0, 32)}~${sessionLabel(label) ?? random()}`;
31
+ let id = base;
32
+ for (let n = 0; taken(id) && n < 8; n++) id = `${base}-${random().slice(0, 4)}`;
33
+ return id;
34
+ }
35
+
36
+ export const isSessionId = (id: unknown): id is string => typeof id === "string" && id.includes("~");
37
+
38
+ /** The one refusal a distinct session meets, so a person reading it learns which session it is and which one holds the role. */
39
+ export function sessionRefusal(id: string): string {
40
+ const peer = id.slice(0, id.indexOf("~"));
41
+ return `this session is ${id}, not this project's ${peer}: it can send and read, and holds none of ${peer}'s board roles`;
42
+ }
43
+
44
+ /** The gate the daemon applies to every message of a distinct session. Returns the refusal text, or undefined when the message may proceed. */
45
+ export function sessionGate(id: string, msg: { t?: unknown; op?: unknown }): string | undefined {
46
+ if (typeof msg.t !== "string" || !SESSION_MESSAGES.has(msg.t)) return sessionRefusal(id);
47
+ if (msg.t === "task" && !(typeof msg.op === "string" && SESSION_READ_OPS.has(msg.op))) return sessionRefusal(id);
48
+ return undefined;
49
+ }