@ours.network/fleet 1.2.0-nightly.62 → 1.2.0-nightly.63

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
@@ -1838,6 +1838,9 @@ See [Room layouts](docs/room-layouts.md) for YAML examples and CLI commands.
1838
1838
 
1839
1839
  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.
1840
1840
 
1841
+ New agent notifications and confirmed role retirement use `/fleet/chats/agents/:roleId`, with each ID escaped once and matching message anchors retained. Deploy the app, notification service, and producers together: old query-based conversation links are intentionally unsupported. Existing pending payload URLs are replayed unchanged for idempotency; obsolete cleanup targets remain stored without being sent or treated as proof of absence. Task management notification paths are unchanged.
1842
+
1843
+
1841
1844
  Existing-host enrollment: `ours-fleet workspace-enroll --file <private-file> --preserve-profile` requires Messenger to advertise preservation support before any enrollment POST. It omits Name/Surname and leaves existing/absent profile untouched. Existing Fleet models and agents are retained; the installer must skip init when its configuration exists. Signed root proof, tunnel setup and device QR issuance remain the normal enrollment flow. Older Messenger fails closed and requires its preservation-capable release.
1842
1845
 
1843
1846
  ### Connect an installed host to the App
@@ -94,7 +94,7 @@ export class RoleRemovalService {
94
94
  await this.options.ops.backend.uninstall(preview.role);
95
95
  if (existsSync(sourceState))
96
96
  renameSync(sourceState, join(recoveryPath, 'orphan-state'));
97
- await retireNotificationTarget(`/fleet/chats?chat=${encodeURIComponent(preview.role)}`).catch(() => { });
97
+ await retireNotificationTarget(`/fleet/chats/agents/${encodeURIComponent(preview.role)}`).catch(() => { });
98
98
  this.options.ops.log(`removed orphaned backend/state '${preview.role}'`);
99
99
  }
100
100
  return { ...preview, removed: true, recoveryPath };
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "1.2.0-nightly.62",
3
- "buildId": "dbb6bd7e2ef8",
4
- "commit": "9162af7adc2521d0e1d5cbd79757919dfff402e3",
2
+ "version": "1.2.0-nightly.63",
3
+ "buildId": "9a1df2251871",
4
+ "commit": "ec4a9231a3a43772d7b7348e3fea407cf83e5bd1",
5
5
  "dirty": true,
6
- "builtAt": "2026-10-13T21:20:26.416Z",
6
+ "builtAt": "2026-10-14T20:26:32.887Z",
7
7
  "capabilities": [
8
8
  "managed-cli.codex-reasoning-effort-v1",
9
9
  "managed-cli.setup-v1",
@@ -1,5 +1,5 @@
1
1
  import { roomsDir } from '../rooms-tasks/room-state.js';
2
- import { drainNotificationTargetCleanup } from './target-cleanup.js';
2
+ import { drainNotificationTargetCleanup, notificationAgentId } from './target-cleanup.js';
3
3
  import { closeSync, existsSync, lstatSync, openSync, readSync, readFileSync, readdirSync, statSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { createHash } from 'node:crypto';
@@ -71,7 +71,7 @@ export class FleetNotificationProducer {
71
71
  }
72
72
  poll() {
73
73
  this.cleanup ??= drainNotificationTargetCleanup(this.config, undefined, undefined, { beforeDelete: async (url) => {
74
- const name = new URL(url, 'https://ours.invalid').searchParams.get('chat');
74
+ const name = notificationAgentId(url);
75
75
  if (!name || !/^[A-Za-z0-9_-]+$/.test(name))
76
76
  return false;
77
77
  // A recreated or retained role still has a valid target. Filesystem errors cannot prove removal.
@@ -126,7 +126,7 @@ export class FleetNotificationProducer {
126
126
  if (!outbox) {
127
127
  const key = createHash('sha256').update(roleDir).digest('hex');
128
128
  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.',
129
- url: `/fleet/chats?chat=${encodeURIComponent(roleId)}&detail=1`, ...value }), this.warn);
129
+ url: `/fleet/chats/agents/${encodeURIComponent(roleId)}`, ...value }), this.warn);
130
130
  if (!outbox.checkpoint) {
131
131
  const segment = this.existing.has(roleDir) ? segments.at(-1) : segments[0];
132
132
  outbox.saveCheckpoint({ segment, offset: this.existing.has(roleDir) ? statSync(join(dir, segment)).size : 0, prompts: [] });
@@ -187,7 +187,7 @@ export class FleetNotificationProducer {
187
187
  if (['message.chunk', 'message.replace'].includes(event.kind) && event.promptId && event.messageId && current.prompts.includes(prompt))
188
188
  current.messages[prompt] = event.messageId;
189
189
  if (event.kind === 'turn.completed' && event.promptId && current.prompts.includes(prompt)) {
190
- if (!outbox.enqueue(`${roleId}:${event.sessionGeneration}:${event.seq}`, { ...(taskRoles ? { taskId: taskRoles.get(roleId) ?? null } : {}), url: `/fleet/chats?chat=${encodeURIComponent(roleId)}&detail=1#fleet-message-${encodeURIComponent(current.messages[prompt] ?? event.promptId)}` }))
190
+ if (!outbox.enqueue(`${roleId}:${event.sessionGeneration}:${event.seq}`, { ...(taskRoles ? { taskId: taskRoles.get(roleId) ?? null } : {}), url: `/fleet/chats/agents/${encodeURIComponent(roleId)}#fleet-message-${encodeURIComponent(current.messages[prompt] ?? event.promptId)}` }))
191
191
  break;
192
192
  current.prompts = current.prompts.filter(p => p !== prompt);
193
193
  delete current.messages[prompt];
@@ -1,4 +1,6 @@
1
1
  import { type ProducerConfig } from './outbox.js';
2
+ /** Only the canonical agent namespace can authorize role-target cleanup. */
3
+ export declare function notificationAgentId(path: string): string | null;
2
4
  /** Queue only after the owning lifecycle has established deletion, never on a failed inventory read. */
3
5
  export declare function retireNotificationTarget(url: string, config?: ProducerConfig | undefined, dir?: string, request?: typeof fetch): Promise<void>;
4
6
  /** Persisted retries also run from the ordinary web notification producer after restart. */
@@ -5,6 +5,24 @@ import { replaceFileAtomically } from '../atomic-file.js';
5
5
  import { stateRoot } from '../paths.js';
6
6
  import { producerConfig } from './outbox.js';
7
7
  const cleanupDir = () => join(stateRoot(), 'notifications', 'removed-targets');
8
+ /** Only the canonical agent namespace can authorize role-target cleanup. */
9
+ export function notificationAgentId(path) {
10
+ try {
11
+ if (typeof path !== 'string' || /[\\\x00-\x20]|%(?:2f|5c|00|0[ad])/i.test(path))
12
+ return null;
13
+ const match = /^\/fleet\/chats\/agents\/([^/?#]+)(?:[?#]|$)/.exec(path);
14
+ if (!match)
15
+ return null;
16
+ const url = new URL(path, 'https://ours.invalid');
17
+ if (['source', 'chat', 'task', 'detail'].some(key => url.searchParams.has(key)))
18
+ return null;
19
+ const id = decodeURIComponent(match[1]);
20
+ return /^[A-Za-z0-9_-]+$/.test(id) ? id : null;
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ }
8
26
  /** Queue only after the owning lifecycle has established deletion, never on a failed inventory read. */
9
27
  export async function retireNotificationTarget(url, config = producerConfig(), dir = cleanupDir(), request = fetch) {
10
28
  if (!config)
@@ -17,11 +35,15 @@ export async function retireNotificationTarget(url, config = producerConfig(), d
17
35
  export async function drainNotificationTargetCleanup(config, dir = cleanupDir(), request = fetch, options = {}) {
18
36
  if (!existsSync(dir))
19
37
  return;
20
- for (const file of readdirSync(dir).filter(file => /^[a-f0-9]{64}\.json$/.test(file)).slice(0, 32)) {
38
+ let attempts = 0;
39
+ for (const file of readdirSync(dir).filter(file => /^[a-f0-9]{64}\.json$/.test(file))) {
40
+ if (attempts >= 32)
41
+ break;
21
42
  try {
22
43
  const path = join(dir, file), value = JSON.parse(readFileSync(path, 'utf8'));
23
- if (typeof value.url !== 'string' || !value.url.startsWith('/fleet/chats?'))
44
+ if (!notificationAgentId(value.url))
24
45
  continue;
46
+ attempts++;
25
47
  if (options.beforeDelete && !(await options.beforeDelete(value.url))) {
26
48
  rmSync(path, { force: true });
27
49
  continue;
package/dist/ops.js CHANGED
@@ -271,7 +271,7 @@ export async function rmRole(cfg, name, deps) {
271
271
  rmSync(agentDir(name), { recursive: true, force: true });
272
272
  if (generatedSource && existsSync(role.sourceFile)) {
273
273
  unlinkSync(role.sourceFile);
274
- await retireNotificationTarget(`/fleet/chats?chat=${encodeURIComponent(name)}`).catch(() => { });
274
+ await retireNotificationTarget(`/fleet/chats/agents/${encodeURIComponent(name)}`).catch(() => { });
275
275
  deps.log(`removed ${role.sourceFile}`);
276
276
  }
277
277
  deps.log(`removed '${name}' (its ours identity is left intact)`);
@@ -56,6 +56,7 @@ export declare class ClaudeLogin extends BaseLogin {
56
56
  private child?;
57
57
  private output;
58
58
  private submitted;
59
+ private continued;
59
60
  /** Resolves with the CLI exit status once it exits. */
60
61
  exited: Promise<number | null>;
61
62
  constructor(owner: string, bins: SubscriptionBinaries, home: string, profileId: string);
@@ -73,13 +73,27 @@ export class ClaudeLogin extends BaseLogin {
73
73
  child;
74
74
  output = '';
75
75
  submitted = false;
76
+ continued = false;
76
77
  /** Resolves with the CLI exit status once it exits. */
77
78
  exited;
78
79
  constructor(owner, bins, home, profileId) {
79
80
  super('claude', owner, 'paste_code');
80
- const child = spawn(bins.script, ['-qefc', `${shellQuote(bins.claude)} auth login`, '/dev/null'], {
81
- env: profileEnv('CLAUDE_CONFIG_DIR', profileId, home), stdio: ['pipe', 'pipe', 'pipe'],
81
+ const darwin = process.platform === 'darwin';
82
+ const args = darwin
83
+ ? ['-q', '/dev/null', bins.claude, 'auth', 'login']
84
+ : ['-qefc', `${shellQuote(bins.claude)} auth login`, '/dev/null'];
85
+ // BSD script accepts a real pipe, but rejects Node's socket-backed stdin.
86
+ // Own the pipeline as one process group so cancellation also stops cat.
87
+ const child = spawn(darwin ? '/bin/sh' : bins.script, darwin
88
+ ? ['-c', '/bin/cat 3>&- | { "$@" 3>&-; result=$?; printf "%s\\n" "$result" >&3; exit "$result"; }', 'fleet-claude-login', bins.script, ...args] : args, {
89
+ detached: darwin,
90
+ env: profileEnv('CLAUDE_CONFIG_DIR', profileId, home),
91
+ stdio: darwin ? ['pipe', 'pipe', 'pipe', 'pipe'] : ['pipe', 'pipe', 'pipe'],
82
92
  });
93
+ // The terminal has exited; release cat's input so the shell can finish.
94
+ // Only the shell owns fd 3. Final close remains the authoritative status.
95
+ if (darwin)
96
+ child.stdio[3]?.once('data', () => child.stdin.end());
83
97
  this.child = child;
84
98
  child.stdin.on('error', () => { });
85
99
  const onData = (chunk) => {
@@ -98,14 +112,16 @@ export class ClaudeLogin extends BaseLogin {
98
112
  }
99
113
  }
100
114
  // Claude asks for Enter after success; the answer carries no secret.
101
- if (this.submitted && /press enter/i.test(this.output))
115
+ if (this.submitted && !this.continued && /press enter/i.test(this.output)) {
116
+ this.continued = true;
102
117
  child.stdin.write('\r');
118
+ }
103
119
  };
104
120
  child.stdout.on('data', onData);
105
121
  child.stderr.on('data', onData);
106
122
  this.exited = new Promise(resolve => {
107
123
  child.once('error', () => { this.end('failed', 'could not start the sign-in'); resolve(null); });
108
- child.once('close', code => resolve(code));
124
+ child.once('close', code => { this.child = undefined; resolve(code); });
109
125
  });
110
126
  }
111
127
  submitCode(code) {
@@ -118,7 +134,16 @@ export class ClaudeLogin extends BaseLogin {
118
134
  this.output = '';
119
135
  this.child?.stdin.write(`${code}\r`);
120
136
  }
121
- stop() { this.child?.kill('SIGTERM'); }
137
+ stop() {
138
+ if (process.platform === 'darwin' && this.child?.pid) {
139
+ try {
140
+ process.kill(-this.child.pid, 'SIGTERM');
141
+ }
142
+ catch { /* already exited */ }
143
+ }
144
+ else
145
+ this.child?.kill('SIGTERM');
146
+ }
122
147
  }
123
148
  /** Codex device-code login through the app-server's supported account API. */
124
149
  export class CodexDeviceLogin extends BaseLogin {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "1.2.0-nightly.62",
3
+ "version": "1.2.0-nightly.63",
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",