@ours.network/fleet 1.2.0-nightly.24 → 1.2.0-nightly.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1820,3 +1820,8 @@ See [predefined Brain models and runtime requirements](presets/README.md) for th
1820
1820
 
1821
1821
  Room layouts describe room composition with shared or fresh participant sessions and independent room closure.
1822
1822
  See [Room layouts](docs/room-layouts.md) for YAML examples and CLI commands.
1823
+
1824
+
1825
+ ### Dedicated notification service
1826
+
1827
+ Configure the web backend with `OURS_NOTIFICATIONS_ORIGIN` and a Fleet-scoped `OURS_NOTIFICATIONS_PRODUCER_TOKEN`. It reads each local supervisor ledger incrementally with persistent checkpoints under web state, correlating owner browser/channel prompts with terminal completion. Monitor/startup turns do not notify. First installation skips existing history; no agent restart or conversation controller is needed. Persist web state across deployment/restarts. Reads are bounded to 256 KiB per role per tick, and oversized model/media records are skipped across chunks. Expose `/notifications` through the generic authenticated prefix gateway with a server-side user token and `stripBrowserContext: true`; producer tokens stay server-side. All browser sessions of this single-owner console map to one stable notification account. See [ours-notifications](https://github.com/adapt-toolkit/ours-notifications) for configuration/API/release details.
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "1.2.0-nightly.24",
3
- "buildId": "80f2457d96f4",
4
- "commit": "b9009b9a68c0c11536c33e239eae38144a491233",
2
+ "version": "1.2.0-nightly.25",
3
+ "buildId": "a567036edf8d",
4
+ "commit": "59ceeac2bb5c52f0861fe19d3c20e5e884d37c6c",
5
5
  "dirty": true,
6
- "builtAt": "2026-09-30T12:55:21.818Z",
6
+ "builtAt": "2026-09-30T20:02:41.728Z",
7
7
  "capabilities": [
8
8
  "cowork.http-management-v1",
9
9
  "monitor.interrupt.after_tool"
@@ -0,0 +1,18 @@
1
+ import { type ProducerConfig } from './outbox.js';
2
+ /** Read durable supervisor ledgers without restarting agents or attaching a
3
+ * conversation controller. Each tick reads at most 256 KiB per role, from its
4
+ * checkpoint; it never loads a complete transcript or requests model history. */
5
+ export declare class FleetNotificationProducer {
6
+ private readonly roots;
7
+ private readonly stateDir;
8
+ private readonly config;
9
+ private readonly warn;
10
+ private readonly queues;
11
+ private readonly timer;
12
+ private readonly existing;
13
+ constructor(roots: string[], stateDir: string, config: ProducerConfig, warn?: (line: string) => void);
14
+ private names;
15
+ poll(): void;
16
+ drain(): Promise<void>;
17
+ close(): Promise<void>;
18
+ }
@@ -0,0 +1,129 @@
1
+ import { closeSync, existsSync, lstatSync, openSync, readSync, readdirSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { NotificationOutbox } from './outbox.js';
5
+ /** Read durable supervisor ledgers without restarting agents or attaching a
6
+ * conversation controller. Each tick reads at most 256 KiB per role, from its
7
+ * checkpoint; it never loads a complete transcript or requests model history. */
8
+ export class FleetNotificationProducer {
9
+ roots;
10
+ stateDir;
11
+ config;
12
+ warn;
13
+ queues = new Map();
14
+ timer;
15
+ existing = new Set();
16
+ constructor(roots, stateDir, config, warn = () => { }) {
17
+ this.roots = roots;
18
+ this.stateDir = stateDir;
19
+ this.config = config;
20
+ this.warn = warn;
21
+ for (const root of roots)
22
+ for (const name of this.names(root))
23
+ this.existing.add(join(root, name));
24
+ this.poll();
25
+ this.timer = setInterval(() => this.poll(), 1000);
26
+ this.timer.unref();
27
+ }
28
+ names(root) {
29
+ try {
30
+ return readdirSync(root, { withFileTypes: true }).filter(d => d.isDirectory() && /^[a-zA-Z0-9_-]+$/.test(d.name)).map(d => d.name);
31
+ }
32
+ catch {
33
+ return [];
34
+ }
35
+ }
36
+ poll() {
37
+ for (const root of this.roots)
38
+ for (const roleId of this.names(root)) {
39
+ const roleDir = join(root, roleId), dir = join(roleDir, '.conversation');
40
+ try {
41
+ if (!existsSync(dir) || lstatSync(dir).isSymbolicLink())
42
+ continue;
43
+ const segments = readdirSync(dir).filter(n => /^events-\d{6}\.jsonl$/.test(n) && !lstatSync(join(dir, n)).isSymbolicLink()).sort();
44
+ if (!segments.length)
45
+ continue;
46
+ let outbox = this.queues.get(roleDir);
47
+ if (!outbox) {
48
+ const key = createHash('sha256').update(roleDir).digest('hex');
49
+ outbox = new NotificationOutbox(join(this.stateDir, key + '.json'), this.config, async (value, eventId) => ({ eventId, title: `${roleId}: agent completed`, body: 'Your agent finished responding. Open the chat to view the result.',
50
+ url: `/fleet/chats?chat=${encodeURIComponent(roleId)}&detail=1`, ...value }), this.warn);
51
+ if (!outbox.checkpoint) {
52
+ const segment = this.existing.has(roleDir) ? segments.at(-1) : segments[0];
53
+ outbox.saveCheckpoint({ segment, offset: this.existing.has(roleDir) ? statSync(join(dir, segment)).size : 0, prompts: [] });
54
+ }
55
+ this.queues.set(roleDir, outbox);
56
+ }
57
+ const saved = outbox.checkpoint;
58
+ const current = { ...saved, prompts: [...saved.prompts], messages: { ...saved.messages } };
59
+ let index = segments.indexOf(current.segment);
60
+ if (index < 0) {
61
+ this.warn('Notification ledger segment unavailable; checkpoint retained');
62
+ continue;
63
+ }
64
+ const file = join(dir, current.segment), size = statSync(file).size;
65
+ if (size < current.offset) {
66
+ this.warn('Notification ledger truncated; checkpoint retained');
67
+ continue;
68
+ }
69
+ if (size === current.offset && index < segments.length - 1) {
70
+ current.segment = segments[++index];
71
+ current.offset = 0;
72
+ }
73
+ const fd = openSync(join(dir, current.segment), 'r'), buffer = Buffer.alloc(256 * 1024);
74
+ let count;
75
+ try {
76
+ count = readSync(fd, buffer, 0, buffer.length, current.offset);
77
+ }
78
+ finally {
79
+ closeSync(fd);
80
+ }
81
+ let position = 0;
82
+ while (position < count) {
83
+ const end = buffer.indexOf(10, position);
84
+ if (end < 0 || end >= count) {
85
+ // Oversized model/media events are irrelevant to prompt/completion
86
+ // correlation. Skip in bounded chunks; retain partial small lines.
87
+ if (current.discarding || (position === 0 && count === buffer.length)) {
88
+ current.offset += count - position;
89
+ current.discarding = true;
90
+ this.warn('Notification observer skipping oversized ledger record');
91
+ }
92
+ break;
93
+ }
94
+ if (current.discarding) {
95
+ current.offset += end - position + 1;
96
+ position = end + 1;
97
+ current.discarding = false;
98
+ continue;
99
+ }
100
+ const event = JSON.parse(buffer.subarray(position, end).toString('utf8'));
101
+ if (event.schemaVersion !== 1 || event.roleId !== roleId)
102
+ throw new Error('invalid ledger event');
103
+ const prompt = `${event.sessionGeneration}:${event.promptId}`;
104
+ if (event.kind === 'prompt.admitted' && event.promptId && ['browser', 'owner_admin_console', 'owner_channel'].includes(event.source ?? '')) {
105
+ if (!current.prompts.includes(prompt))
106
+ current.prompts.push(prompt);
107
+ }
108
+ if (['message.chunk', 'message.replace'].includes(event.kind) && event.promptId && event.messageId && current.prompts.includes(prompt))
109
+ current.messages[prompt] = event.messageId;
110
+ if (event.kind === 'turn.completed' && event.promptId && current.prompts.includes(prompt)) {
111
+ if (!outbox.enqueue(`${roleId}:${event.sessionGeneration}:${event.seq}`, { url: `/fleet/chats?chat=${encodeURIComponent(roleId)}&detail=1#fleet-message-${encodeURIComponent(current.messages[prompt] ?? event.promptId)}` }))
112
+ break;
113
+ current.prompts = current.prompts.filter(p => p !== prompt);
114
+ delete current.messages[prompt];
115
+ }
116
+ current.offset += end - position + 1;
117
+ position = end + 1;
118
+ }
119
+ if (current.segment !== saved.segment || current.offset !== saved.offset)
120
+ outbox.saveCheckpoint(current);
121
+ }
122
+ catch {
123
+ this.warn('Notification ledger admission pending; checkpoint will replay');
124
+ }
125
+ }
126
+ }
127
+ async drain() { await Promise.all([...this.queues.values()].map(q => q.drain())); }
128
+ async close() { clearInterval(this.timer); await Promise.all([...this.queues.values()].map(q => q.close())); }
129
+ }
@@ -0,0 +1,31 @@
1
+ export interface NotificationInput {
2
+ eventId: string;
3
+ title: string;
4
+ body: string;
5
+ url: string;
6
+ }
7
+ export interface ProducerConfig {
8
+ origin: string;
9
+ token: string;
10
+ }
11
+ export declare function producerConfig(env?: NodeJS.ProcessEnv): ProducerConfig | undefined;
12
+ /** Durable producer queue. Service dedupe makes crash-after-acceptance replay safe. */
13
+ export declare class NotificationOutbox {
14
+ private readonly file;
15
+ private readonly config;
16
+ private readonly project;
17
+ private readonly warn;
18
+ private entries;
19
+ cursor?: number;
20
+ checkpoint?: unknown;
21
+ private running?;
22
+ private timer;
23
+ constructor(file: string, config: ProducerConfig, project: (value: unknown, id: string) => Promise<NotificationInput>, warn?: (message: string) => void);
24
+ private persist;
25
+ advance(cursor: number): void;
26
+ saveCheckpoint(checkpoint: unknown): void;
27
+ enqueue(id: string, value: unknown, cursor?: number | undefined): boolean;
28
+ drain(): Promise<void>;
29
+ private drainOnce;
30
+ close(): Promise<void>;
31
+ }
@@ -0,0 +1,104 @@
1
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ export function producerConfig(env = process.env) {
4
+ const origin = env.OURS_NOTIFICATIONS_ORIGIN, token = env.OURS_NOTIFICATIONS_PRODUCER_TOKEN;
5
+ if (!origin && !token)
6
+ return;
7
+ if (!origin || !token || token.length < 32)
8
+ throw new Error('notifications producer requires origin and scoped token');
9
+ const url = new URL(origin);
10
+ if (!['http:', 'https:'].includes(url.protocol) || url.origin !== origin || url.username || url.password)
11
+ throw new Error('invalid notification service origin');
12
+ if (url.protocol === 'http:' && !['127.0.0.1', '[::1]', 'localhost'].includes(url.hostname))
13
+ throw new Error('notification credentials require HTTPS or loopback');
14
+ return { origin, token };
15
+ }
16
+ /** Durable producer queue. Service dedupe makes crash-after-acceptance replay safe. */
17
+ export class NotificationOutbox {
18
+ file;
19
+ config;
20
+ project;
21
+ warn;
22
+ entries;
23
+ cursor;
24
+ checkpoint;
25
+ running;
26
+ timer;
27
+ constructor(file, config, project, warn = () => { }) {
28
+ this.file = file;
29
+ this.config = config;
30
+ this.project = project;
31
+ this.warn = warn;
32
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
33
+ const saved = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : { entries: [] };
34
+ this.entries = Array.isArray(saved) ? saved : saved.entries;
35
+ this.cursor = saved.cursor;
36
+ this.checkpoint = saved.checkpoint;
37
+ if (!Array.isArray(this.entries))
38
+ throw new Error('invalid notification outbox');
39
+ this.timer = setInterval(() => { void this.drain(); }, 3000);
40
+ this.timer.unref();
41
+ }
42
+ persist(entries, cursor = this.cursor, checkpoint = this.checkpoint) {
43
+ const tmp = this.file + '.tmp', fd = openSync(tmp, 'w', 0o600);
44
+ try {
45
+ writeFileSync(fd, JSON.stringify({ entries, cursor, checkpoint }));
46
+ fsyncSync(fd);
47
+ }
48
+ finally {
49
+ closeSync(fd);
50
+ }
51
+ renameSync(tmp, this.file);
52
+ const dir = openSync(dirname(this.file), 'r');
53
+ try {
54
+ fsyncSync(dir);
55
+ }
56
+ finally {
57
+ closeSync(dir);
58
+ }
59
+ this.entries = entries;
60
+ this.cursor = cursor;
61
+ this.checkpoint = checkpoint;
62
+ }
63
+ advance(cursor) { this.persist(this.entries, cursor); }
64
+ saveCheckpoint(checkpoint) { this.persist(this.entries, this.cursor, checkpoint); }
65
+ enqueue(id, value, cursor = this.cursor) {
66
+ if (this.entries.some(e => e.id === id)) {
67
+ if (cursor !== this.cursor)
68
+ this.advance(cursor);
69
+ return true;
70
+ }
71
+ if (this.entries.length >= 2000)
72
+ return false;
73
+ this.persist([...this.entries, { id, value }], cursor);
74
+ return true;
75
+ }
76
+ drain() {
77
+ return this.running ??= this.drainOnce().finally(() => { this.running = undefined; });
78
+ }
79
+ async drainOnce() {
80
+ for (const entry of [...this.entries].slice(0, 32)) {
81
+ try {
82
+ const value = entry.payload ?? await this.project(entry.value, entry.id);
83
+ // Freeze the canonical projection before any network attempt. A crash
84
+ // after service acceptance must replay the identical dedupe payload.
85
+ if (!entry.payload)
86
+ this.persist(this.entries.map(e => e.id === entry.id ? { ...e, payload: value } : e));
87
+ const response = await fetch(this.config.origin + '/api/v1/send', {
88
+ method: 'POST', redirect: 'error', signal: AbortSignal.timeout(10_000),
89
+ headers: { Authorization: `Bearer ${this.config.token}`, 'Content-Type': 'application/json' },
90
+ body: JSON.stringify(value),
91
+ });
92
+ await response.body?.cancel();
93
+ if (!response.ok)
94
+ throw new Error('notification service rejected event');
95
+ this.persist(this.entries.filter(e => e.id !== entry.id));
96
+ }
97
+ catch {
98
+ this.warn('Notification producer delivery pending; retrying from durable outbox');
99
+ break;
100
+ }
101
+ }
102
+ }
103
+ async close() { clearInterval(this.timer); await this.running; }
104
+ }
@@ -6,6 +6,8 @@ export interface ServiceTarget {
6
6
  origin: string;
7
7
  /** Server-side service credentials; never taken from browser headers. */
8
8
  headers?: Record<string, string>;
9
+ /** Dedicated credential-authenticated services reject direct browser context. */
10
+ stripBrowserContext?: boolean;
9
11
  }
10
12
  export interface GatewayOptions {
11
13
  /** Backend mode is for a separate loopback machine-client listener; browsers are rejected. */
@@ -45,6 +45,10 @@ export function createPrefixGateway(options) {
45
45
  for (const name of ['cookie', 'authorization', 'x-csrf-token', 'x-ours-api-token'])
46
46
  delete headers[name];
47
47
  Object.assign(headers, route.service.headers);
48
+ if (route.service.stripBrowserContext) {
49
+ for (const name of ['origin', 'sec-fetch-site', 'sec-fetch-mode', 'sec-fetch-dest', 'sec-fetch-user'])
50
+ delete headers[name];
51
+ }
48
52
  headers.host = new URL(route.origin).host;
49
53
  }
50
54
  if (upgrade) {
@@ -1,6 +1,8 @@
1
1
  import { LayoutBindingGrants } from '../rooms-tasks/layout-binding-grants.js';
2
2
  import { RoomLayoutService } from '../rooms-tasks/layout-service.js';
3
3
  import { spawn } from 'node:child_process';
4
+ import { FleetNotificationProducer } from '../notifications/fleet-producer.js';
5
+ import { producerConfig } from '../notifications/outbox.js';
4
6
  import { SubscriptionService } from '../subscriptions/service.js';
5
7
  import { existsSync, realpathSync } from 'node:fs';
6
8
  import { resolve } from 'node:path';
@@ -16,7 +18,7 @@ import { FleetError } from '../application/errors.js';
16
18
  import { controlRequest, controlSocketPath } from '../session/control.js';
17
19
  import { pickBackend } from '../supervisor/index.js';
18
20
  import { realExec } from '../exec.js';
19
- import { home, stateRoot } from '../paths.js';
21
+ import { home, stateRoot, agentsRoot, tmpRoot } from '../paths.js';
20
22
  import { AuditSink } from './audit.js';
21
23
  import { FleetEventBus } from './events.js';
22
24
  import { buildWebServer } from './server.js';
@@ -52,6 +54,7 @@ function cachedConfigProvider(configPath) {
52
54
  };
53
55
  }
54
56
  export async function startWebConsole(options) {
57
+ const notificationConfig = producerConfig();
55
58
  const requestedPort = options.port ?? 49_271;
56
59
  if (!Number.isInteger(requestedPort) || requestedPort < 0 || requestedPort > 65_535)
57
60
  throw new FleetError('invalid_request', 'port must be between 0 and 65535');
@@ -236,10 +239,12 @@ export async function startWebConsole(options) {
236
239
  if (options.open !== false)
237
240
  openBrowser(access.mode === 'pairing'
238
241
  ? `${browserOrigin}/#bootstrap=${server.auth.bootstrapSecret}` : `${browserOrigin}/`);
242
+ const notifications = notificationConfig ? new FleetNotificationProducer([agentsRoot(), tmpRoot()], resolve(webDir, 'notification-outboxes'), notificationConfig, options.log) : undefined;
239
243
  return {
240
244
  ...server, address: browserOrigin,
241
245
  async close() {
242
246
  try {
247
+ await notifications?.close();
243
248
  await control?.close();
244
249
  await server.close();
245
250
  }
@@ -937,7 +937,11 @@ export async function buildWebServer(services, boundary, options = {}) {
937
937
  app.setNotFoundHandler((request, reply) => {
938
938
  if (request.url.startsWith('/api/'))
939
939
  return reply.code(404).send({ error: 'not found' });
940
- return reply.sendFile('index.html');
940
+ const path = request.url.split('?')[0];
941
+ if ((path === '/chats' || path.startsWith('/chats/')) && existsSync(join(staticRoot, 'fleet-index.html')))
942
+ return reply.redirect((request.url.includes('?') ? '/fleet/chats' : '/fleet') + (request.url.includes('?') ? request.url.slice(request.url.indexOf('?')) : ''));
943
+ const fleetEntry = (path === '/fleet' || path.startsWith('/fleet/')) && existsSync(join(staticRoot, 'fleet-index.html'));
944
+ return reply.sendFile(fleetEntry ? 'fleet-index.html' : 'index.html');
941
945
  });
942
946
  }
943
947
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "1.2.0-nightly.24",
3
+ "version": "1.2.0-nightly.25",
4
4
  "description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, managed native/ACP sessions, supervision, and ours.network messaging.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",