@ours.network/fleet 1.2.0-nightly.45 → 1.2.0-nightly.47

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
@@ -1856,6 +1856,58 @@ pipe with a 32KiB limit and refuses a terminal. Legacy v1 payloads, which contai
1856
1856
  long-lived scoped credentials, are accepted only through file/stdin, never as
1857
1857
  command arguments. Use only one payload transport.
1858
1858
 
1859
+ When this host already has a registration, Fleet inspects the one-use grant without
1860
+ consuming it. The same workspace reruns without a deletion prompt or retirement.
1861
+ An account-origin change for that workspace requires `--migrate-app-origin`; a
1862
+ changed server identity is refused. An older App without inspection support falls
1863
+ back to asking before redemption. An unavailable inspection service preserves the
1864
+ grant and setup. Fleet verifies the redeemed destination against the inspection.
1865
+
1866
+ For another workspace, setup describes the existing workspace, tunnel hostname and
1867
+ account origin and asks `[y/N]`. No answer or `no` preserves the setup and grant; a
1868
+ noninteractive caller must pass `--replace-registration`, otherwise it exits with
1869
+ an error. A legacy binding without a server identity must rerun its own workspace
1870
+ first. The original Human root signs retirement. Fleet waits for the account's
1871
+ durable cleanup receipt before stopping the old service and revoking its linked
1872
+ devices. If a migrated database has no old registration, the signed command confirms
1873
+ absence; the service has no old provider resources to delete. Installation identities,
1874
+ profile, agents, history, stable host ID and local pairing are preserved.
1875
+
1876
+ Replacement recovery is saved in an owned private `workspace/replacement.json`
1877
+ after root, host, service and server-contact preconditions pass. The App must
1878
+ explicitly support deadline-enforced retirement; an older App without that
1879
+ capability preserves the existing setup. `setup-tunnel --resume` retries its nonce
1880
+ and saved operation deadline (24 hours less a five-minute clock margin).
1881
+ If the enrollment challenge expires, obtain a fresh App command for the same target
1882
+ and pass `--replace-registration`; completed retirement is retained. The old server
1883
+ identity is pinned: only its own invitation can reconnect it.
1884
+
1885
+ `setup-tunnel --abandon-replacement` removes the private recovery payload only when
1886
+ a fresh private receipt confirms no deletion and the operation was never dispatched
1887
+ or was terminally rejected. A lost transport response remains uncertain. Expired
1888
+ operations require a fresh receipt proving the old binding still exists after the
1889
+ deadline plus five minutes and an explicit server guarantee that the deadline is enforced;
1890
+ older recovery records without that guarantee must be resumed. Abandoning preserves
1891
+ the existing binding, connector and installation data. A terminally rejected operation
1892
+ requires abandonment and a new App command, rather than reusing its nonce.
1893
+ After that deadline, a fresh command for the same target can also resume with a
1894
+ new operation nonce after checking the enforced expiry. This confirms signed
1895
+ absence if an old successful receipt was lost and later pruned.
1896
+
1897
+ If the App accepted the successor proof before Fleet wrote its local binding,
1898
+ a fresh command for that target retains the original private connector from the
1899
+ recovery record. If that successor cannot be recovered, remove it in the App,
1900
+ then use `setup-tunnel --discard-retired-replacement` and a fresh App command.
1901
+ Discard is allowed only after retirement and local cleanup, while the old local
1902
+ binding remains. It removes only the recovery record. This host has no working
1903
+ registration until the new setup completes; the old service was stopped and its
1904
+ linked devices revoked.
1905
+
1906
+ Renewal commands contain no existing live connector credential. Fleet keeps the
1907
+ current private connector byte for byte, and accepts renewal only for its recorded
1908
+ workspace, server, origin and tunnel hostname. Restore a missing or unsafe connector,
1909
+ or remove and re-create that workspace in the App before setting it up again.
1910
+
1859
1911
  After service and signed tunnel-target confirmation, setup prints both a QR and
1860
1912
  ordinary private single-use connection code. The App checks readiness automatically
1861
1913
  and immediately opens the code field. If setup or code output fails after binding,
@@ -1,3 +1,4 @@
1
+ import { retireNotificationTarget } from '../notifications/target-cleanup.js';
1
2
  import { cpSync, existsSync, mkdirSync, readdirSync, renameSync } from 'node:fs';
2
3
  import { basename, dirname, join } from 'node:path';
3
4
  import { loadConfig, ROLE_NAME_RE } from '../config.js';
@@ -87,6 +88,7 @@ export class RoleRemovalService {
87
88
  await this.options.ops.backend.uninstall(preview.role);
88
89
  if (existsSync(sourceState))
89
90
  renameSync(sourceState, join(recoveryPath, 'orphan-state'));
91
+ await retireNotificationTarget(`/fleet/chats?chat=${encodeURIComponent(preview.role)}`).catch(() => { });
90
92
  this.options.ops.log(`removed orphaned backend/state '${preview.role}'`);
91
93
  }
92
94
  return { ...preview, removed: true, recoveryPath };
@@ -55,8 +55,14 @@ export interface RoleSessionControl {
55
55
  text: string;
56
56
  actorBrowserSession: string;
57
57
  expectedSessionGeneration?: string;
58
+ requireIdle?: boolean;
58
59
  source: 'owner_admin_console';
59
60
  }): Promise<PromptReceipt>;
61
+ interruptPromptV2?(request: {
62
+ commandId: string;
63
+ expectedSessionGeneration: string;
64
+ promptId: string;
65
+ }): Promise<InterruptReceipt>;
60
66
  interruptV2?(commandId: string): Promise<InterruptReceipt & {
61
67
  commandId: string;
62
68
  }>;
@@ -109,8 +115,14 @@ export declare class RoleSessionControlAdapter implements RoleSessionControl {
109
115
  text: string;
110
116
  actorBrowserSession: string;
111
117
  expectedSessionGeneration?: string;
118
+ requireIdle?: boolean;
112
119
  source: 'owner_admin_console';
113
120
  }): Promise<PromptReceipt>;
121
+ interruptPromptV2(request: {
122
+ commandId: string;
123
+ expectedSessionGeneration: string;
124
+ promptId: string;
125
+ }): Promise<InterruptReceipt>;
114
126
  interruptV2(commandId: string): Promise<InterruptReceipt & {
115
127
  commandId: string;
116
128
  }>;
@@ -106,6 +106,9 @@ export class RoleSessionControlAdapter {
106
106
  // A prompt bound to a session generation (voice or attachments) is checked atomically by supervisors that
107
107
  // support it. Older supervisors do not know that command and never answer, so check the generation here
108
108
  // and submit an ordinary prompt; its command id keeps retries idempotent either way.
109
+ const features = request.requireIdle ? (await this.describe()).features : undefined;
110
+ if (request.requireIdle && (!request.expectedSessionGeneration || !features?.includes('idle_bound_prompts')))
111
+ throw new FleetError('capability_unavailable', 'Live voice requires an updated supervisor.');
109
112
  let command = 'submit_prompt_v2';
110
113
  if (request.expectedSessionGeneration) {
111
114
  if ((await this.describe()).features.includes('generation_bound_prompts'))
@@ -119,18 +122,33 @@ export class RoleSessionControlAdapter {
119
122
  try {
120
123
  return await this.call(command, {
121
124
  commandId: request.commandId, text: request.text, actor: request.actorBrowserSession,
122
- source: request.source, expectedSessionGeneration: request.expectedSessionGeneration,
125
+ source: request.source, expectedSessionGeneration: request.expectedSessionGeneration, requireIdle: request.requireIdle,
123
126
  });
124
127
  }
125
128
  catch (error) {
126
129
  const fleetError = normalizeError(error);
127
130
  if (fleetError.message.includes('session_changed:'))
128
131
  throw new FleetError('stale_state', 'Agent session changed. Record a new voice message.');
132
+ if (fleetError.message.includes('voice_busy:'))
133
+ throw new FleetError('conflict', 'Agent is already working. Wait for the current turn.');
129
134
  if (fleetError.message.includes('idempotency_conflict'))
130
135
  throw new FleetError('idempotency_conflict', 'this command id was already used with a different prompt body');
131
136
  throw fleetError;
132
137
  }
133
138
  }
139
+ async interruptPromptV2(request) {
140
+ if (!(await this.describe()).features.includes('targeted_voice_interrupt'))
141
+ throw new FleetError('capability_unavailable', 'Live voice requires an updated supervisor.');
142
+ try {
143
+ return await this.call('interrupt_voice_v2', request);
144
+ }
145
+ catch (error) {
146
+ const failure = normalizeError(error);
147
+ if (failure.message.includes('session_changed:'))
148
+ throw new FleetError('stale_state', 'Agent session changed.');
149
+ throw failure;
150
+ }
151
+ }
134
152
  async interruptV2(commandId) {
135
153
  const receipt = await this.call('interrupt_v2', { commandId });
136
154
  return {
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "1.2.0-nightly.45",
3
- "buildId": "9af6d0fa4b7e",
4
- "commit": "f59c69b6662ae09b5e72ea2c4c0a958729e1928f",
2
+ "version": "1.2.0-nightly.47",
3
+ "buildId": "bbc0b3f7e8a7",
4
+ "commit": "5a8f562be01ca289722c0504f70281f2ce6d64e1",
5
5
  "dirty": true,
6
- "builtAt": "2026-10-04T12:42:35.826Z",
6
+ "builtAt": "2026-10-05T15:15:27.375Z",
7
7
  "capabilities": [
8
8
  "managed-cli.setup-v1",
9
9
  "workspace.enroll.preserve-profile-v1",
package/dist/cli.js CHANGED
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
+ import { beginWorkspaceReplacement, abandonWorkspaceReplacement, discardRetiredWorkspaceReplacement, clearWorkspaceReplacement, confirmWorkspaceReplacement, finishWorkspaceReplacement, readWorkspaceBinding, readWorkspaceReplacement, requiresWorkspaceReplacement, refreshWorkspaceReplacement } from './workspace-replacement.js';
2
3
  import { completeTunnelSetup } from './tunnel-setup-completion.js';
3
- import { readTunnelSetupFile, readTunnelSetupStdin, readTunnelSetupArgument, redeemTunnelSetup } from './tunnel-setup-input.js';
4
+ import { readTunnelSetupFile, readTunnelSetupStdin, readTunnelSetupArgument, redeemTunnelSetup, inspectTunnelSetup, verifyTunnelSetupTarget } from './tunnel-setup-input.js';
4
5
  import { ensureMinimalSetup } from './minimal-setup.js';
5
- import { confirmWorkspaceTarget } from './workspace-enrollment.js';
6
+ import { confirmWorkspaceTarget, checkWorkspaceConnector } from './workspace-enrollment.js';
6
7
  import { clearPendingTunnelSetup, readPendingTunnelSetup, requiredPortAfterExpiry, savePendingTunnelSetup, setupTunnelPreflight } from './setup-tunnel-preflight.js';
7
8
  import { availableWebPort } from './web/available-port.js';
8
9
  import QRCode from 'qrcode';
@@ -1609,9 +1610,23 @@ webCommand.command('uninstall').description('stop and uninstall the owner web se
1609
1610
  });
1610
1611
  async function workspaceEnrollmentCommand(opts, setupTunnel = false, argument) {
1611
1612
  try {
1613
+ if (opts.discardRetiredReplacement) {
1614
+ if (!setupTunnel || argument !== undefined || opts.file || opts.stdin || opts.check || opts.resume || opts.migrateAppOrigin || opts.replaceRegistration || opts.configuration || opts.abandonReplacement)
1615
+ throw Error('--discard-retired-replacement takes no payload or other setup options');
1616
+ discardRetiredWorkspaceReplacement();
1617
+ process.stdout.write('Retired replacement recovery discarded. This host has no working registration: the old service was stopped and linked devices revoked. After removing the failed successor in the App, use a fresh command to complete a new registration. Installation data and the old local binding are preserved.\n');
1618
+ return;
1619
+ }
1620
+ if (opts.abandonReplacement) {
1621
+ if (!setupTunnel || argument !== undefined || opts.file || opts.stdin || opts.check || opts.resume || opts.migrateAppOrigin || opts.replaceRegistration || opts.configuration)
1622
+ throw Error('--abandon-replacement takes no payload or other setup options');
1623
+ await abandonWorkspaceReplacement();
1624
+ process.stdout.write('Unsent or rejected replacement abandoned; existing local setup preserved. Obtain a fresh App command for another setup.\n');
1625
+ return;
1626
+ }
1612
1627
  if (setupTunnel && opts.check && opts.resume)
1613
1628
  throw Error('Use either --check or --resume');
1614
- if (setupTunnel && (opts.check || opts.resume) && (argument !== undefined || opts.file || opts.stdin || opts.migrateAppOrigin))
1629
+ if (setupTunnel && (opts.check || opts.resume) && (argument !== undefined || opts.file || opts.stdin || opts.migrateAppOrigin || opts.replaceRegistration))
1615
1630
  throw Error('--check and --resume take no payload or migration flag');
1616
1631
  if (setupTunnel && opts.check) {
1617
1632
  await setupTunnelPreflight();
@@ -1621,10 +1636,22 @@ async function workspaceEnrollmentCommand(opts, setupTunnel = false, argument) {
1621
1636
  }
1622
1637
  let pending;
1623
1638
  if (setupTunnel && opts.resume) {
1624
- pending = readPendingTunnelSetup();
1625
- if (opts.configuration && resolvePath(opts.configuration) !== pending.configuration)
1626
- throw Error(`This setup started with configuration ${pending.configuration}; resume it without --configuration`);
1627
- await setupTunnelPreflight();
1639
+ const replacement = readWorkspaceReplacement();
1640
+ if (replacement) {
1641
+ if (opts.configuration && resolvePath(opts.configuration) !== replacement.configuration)
1642
+ throw Error('Resume replacement with its original configuration');
1643
+ await setupTunnelPreflight();
1644
+ const result = await finishWorkspaceReplacement(replacement);
1645
+ pending = { appOrigin: replacement.payload.appOrigin, origin: result.origin, hostWorkspaceId: result.hostWorkspaceId, rootCid: result.rootCid, challenge: replacement.payload.challenge, configuration: replacement.configuration };
1646
+ savePendingTunnelSetup(pending);
1647
+ clearWorkspaceReplacement();
1648
+ }
1649
+ else {
1650
+ pending = readPendingTunnelSetup();
1651
+ if (opts.configuration && resolvePath(opts.configuration) !== pending.configuration)
1652
+ throw Error(`This setup started with configuration ${pending.configuration}; resume it without --configuration`);
1653
+ await setupTunnelPreflight();
1654
+ }
1628
1655
  }
1629
1656
  else {
1630
1657
  if (argument === undefined && !opts.file && !opts.stdin)
@@ -1634,18 +1661,49 @@ async function workspaceEnrollmentCommand(opts, setupTunnel = false, argument) {
1634
1661
  if (opts.stdin && process.stdin.isTTY)
1635
1662
  throw Error('--stdin requires a pipe; use an owned private --file instead');
1636
1663
  const input = setupTunnel ? (argument !== undefined ? readTunnelSetupArgument(argument) : opts.stdin ? await readTunnelSetupStdin() : readTunnelSetupFile(opts.file)) : undefined;
1664
+ const previous = setupTunnel ? readWorkspaceBinding() : undefined;
1665
+ const replacement = setupTunnel ? readWorkspaceReplacement() : undefined;
1666
+ if (replacement && !opts.replaceRegistration)
1667
+ throw Error('An unfinished replacement exists. Run setup-tunnel --resume, use a fresh command for the same target with --replace-registration, or --abandon-replacement when retirement was never sent or definitively rejected. If retirement and cleanup completed but the successor cannot be recovered, remove the successor in the App and use --discard-retired-replacement');
1668
+ if (replacement && resolvePath(opts.configuration ?? defaultConfigPath()) !== replacement.configuration)
1669
+ throw Error('Replacement requires its original configuration');
1670
+ const target = previous && !replacement ? await inspectTunnelSetup(input) : undefined;
1671
+ const different = previous && !replacement && (!target || requiresWorkspaceReplacement(previous, target, opts.migrateAppOrigin));
1672
+ if (different && !(await confirmWorkspaceReplacement(previous, opts.replaceRegistration))) {
1673
+ if (!process.stdin.isTTY)
1674
+ throw Error('Replacement confirmation required; existing setup and grant preserved');
1675
+ return;
1676
+ }
1637
1677
  if (setupTunnel) {
1638
1678
  await setupTunnelPreflight();
1639
1679
  await checkWorkspaceConfiguration(opts.configuration ?? defaultConfigPath());
1640
1680
  }
1641
- const payload = setupTunnel ? await redeemTunnelSetup(input) : readWorkspacePayload(opts.file);
1681
+ let payload = setupTunnel ? await redeemTunnelSetup(input) : readWorkspacePayload(opts.file);
1682
+ verifyTunnelSetupTarget(target, payload);
1683
+ const replace = previous && !replacement && requiresWorkspaceReplacement(previous, { workspaceId: payload.challenge.workspaceId, serverCid: payload.serverCid, appOrigin: payload.appOrigin }, opts.migrateAppOrigin);
1684
+ if (replacement) {
1685
+ refreshWorkspaceReplacement(replacement, payload, opts.configuration ?? defaultConfigPath());
1686
+ payload = replacement.payload;
1687
+ }
1688
+ checkWorkspaceConnector(payload);
1642
1689
  if (setupTunnel)
1643
1690
  await ensureMinimalSetup(opts.configuration ?? defaultConfigPath());
1644
- const result = await enrollWorkspace(payload, opts.configuration, { migrateAppOrigin: opts.migrateAppOrigin, preserveProfile: setupTunnel || opts.preserveProfile });
1691
+ let result;
1692
+ if (replacement) {
1693
+ result = await finishWorkspaceReplacement(replacement);
1694
+ }
1695
+ else if (replace) {
1696
+ const record = await beginWorkspaceReplacement(previous, payload, opts.configuration ?? defaultConfigPath());
1697
+ result = await finishWorkspaceReplacement(record);
1698
+ }
1699
+ else
1700
+ result = await enrollWorkspace(payload, opts.configuration, { migrateAppOrigin: opts.migrateAppOrigin, preserveProfile: setupTunnel || opts.preserveProfile });
1645
1701
  pending = { appOrigin: payload.appOrigin, origin: result.origin, hostWorkspaceId: result.hostWorkspaceId, rootCid: result.rootCid, challenge: payload.challenge, configuration: resolvePath(opts.configuration ?? defaultConfigPath()) };
1646
- // The proof is one-time: keep what the remaining steps need so they can be finished without a new command.
1647
- if (setupTunnel)
1702
+ // Keep both the replacement capability and the proof until the next recovery record is durable.
1703
+ if (setupTunnel) {
1648
1704
  savePendingTunnelSetup(pending);
1705
+ clearWorkspaceReplacement();
1706
+ }
1649
1707
  }
1650
1708
  try {
1651
1709
  await finishWorkspaceService(pending, opts.resume ? pending.configuration : opts.configuration, setupTunnel);
@@ -1739,7 +1797,10 @@ cOpt(program.command('setup-tunnel').argument('[payload]', 'minimal one-use v2 A
1739
1797
  .option('--file <path>', 'owned private App grant or legacy payload file (chmod 600)')
1740
1798
  .option('--stdin', 'read the private App grant or legacy payload from a pipe')
1741
1799
  .option('--check', 'only verify host prerequisites; changes nothing and needs no payload')
1742
- .option('--resume', 'finish a setup that stopped after this host was bound; needs no new payload')
1800
+ .option('--resume', 'finish an interrupted replacement or setup; needs no new payload')
1801
+ .option('--discard-retired-replacement', 'forget a failed successor after retirement and local cleanup; remove it in the App first')
1802
+ .option('--abandon-replacement', 'discard an unsent or definitively rejected replacement after checking its private receipt')
1803
+ .option('--replace-registration', 'confirm retirement of the existing account registration when the new one differs')
1743
1804
  .option('--migrate-app-origin', 'explicitly migrate the same workspace to its new account origin after verified signed proof')
1744
1805
  .action(async (payload, opts) => workspaceEnrollmentCommand(opts, true, payload));
1745
1806
  program.command('link-device').description('create a single-use workspace device connection code (expires in five minutes)')
@@ -10,6 +10,7 @@ export declare class FleetNotificationProducer {
10
10
  private readonly queues;
11
11
  private readonly timer;
12
12
  private readonly existing;
13
+ private cleanup?;
13
14
  constructor(roots: string[], stateDir: string, config: ProducerConfig, warn?: (line: string) => void);
14
15
  private names;
15
16
  poll(): void;
@@ -1,7 +1,42 @@
1
- import { closeSync, existsSync, lstatSync, openSync, readSync, readdirSync, statSync } from 'node:fs';
1
+ import { roomsDir } from '../rooms-tasks/room-state.js';
2
+ import { drainNotificationTargetCleanup } from './target-cleanup.js';
3
+ import { closeSync, existsSync, lstatSync, openSync, readSync, readFileSync, readdirSync, statSync } from 'node:fs';
2
4
  import { join } from 'node:path';
3
5
  import { createHash } from 'node:crypto';
4
6
  import { NotificationOutbox } from './outbox.js';
7
+ /** Unlike a UI inventory, unread classification must not treat unreadable records as absence. */
8
+ function taskRoleAssociations() {
9
+ const dir = roomsDir(), result = new Map();
10
+ let files;
11
+ try {
12
+ files = readdirSync(dir);
13
+ }
14
+ catch (error) {
15
+ if (error.code === 'ENOENT')
16
+ return result;
17
+ throw error;
18
+ }
19
+ for (const name of files.filter(name => name.endsWith('.json'))) {
20
+ const file = join(dir, name), stat = lstatSync(file);
21
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024 * 1024)
22
+ throw Error('Unreadable room association');
23
+ const room = JSON.parse(readFileSync(file, 'utf8'));
24
+ if (!room || typeof room.room_id !== 'string' || !Array.isArray(room.member_seats))
25
+ throw Error('Invalid room association');
26
+ if (room.task_id === undefined)
27
+ continue;
28
+ if (typeof room.task_id !== 'string' || !/^[A-Za-z0-9_-]{1,120}$/.test(room.task_id))
29
+ throw Error('Invalid task association');
30
+ for (const seat of room.member_seats) {
31
+ if (typeof seat.role_name !== 'string')
32
+ throw Error('Invalid member association');
33
+ if (result.has(seat.role_name) && result.get(seat.role_name) !== room.task_id)
34
+ throw Error('Ambiguous task association');
35
+ result.set(seat.role_name, room.task_id);
36
+ }
37
+ }
38
+ return result;
39
+ }
5
40
  /** Read durable supervisor ledgers without restarting agents or attaching a
6
41
  * conversation controller. Each tick reads at most 256 KiB per role, from its
7
42
  * checkpoint; it never loads a complete transcript or requests model history. */
@@ -13,6 +48,7 @@ export class FleetNotificationProducer {
13
48
  queues = new Map();
14
49
  timer;
15
50
  existing = new Set();
51
+ cleanup;
16
52
  constructor(roots, stateDir, config, warn = () => { }) {
17
53
  this.roots = roots;
18
54
  this.stateDir = stateDir;
@@ -34,6 +70,49 @@ export class FleetNotificationProducer {
34
70
  }
35
71
  }
36
72
  poll() {
73
+ this.cleanup ??= drainNotificationTargetCleanup(this.config, undefined, undefined, { beforeDelete: async (url) => {
74
+ const name = new URL(url, 'https://ours.invalid').searchParams.get('chat');
75
+ if (!name || !/^[A-Za-z0-9_-]+$/.test(name))
76
+ return false;
77
+ // A recreated or retained role still has a valid target. Filesystem errors cannot prove removal.
78
+ for (const root of this.roots) {
79
+ try {
80
+ lstatSync(join(root, name));
81
+ return false;
82
+ }
83
+ catch (error) {
84
+ if (error.code !== 'ENOENT')
85
+ throw error;
86
+ }
87
+ }
88
+ for (const root of this.roots) {
89
+ const roleDir = join(root, name), queue = this.queues.get(roleDir);
90
+ if (queue) {
91
+ await queue.retire();
92
+ this.queues.delete(roleDir);
93
+ }
94
+ else {
95
+ const file = join(this.stateDir, createHash('sha256').update(roleDir).digest('hex') + '.json');
96
+ if (existsSync(file)) {
97
+ const queue = new NotificationOutbox(file, this.config, async () => { throw Error('retired target'); });
98
+ try {
99
+ await queue.retire();
100
+ }
101
+ finally {
102
+ await queue.close();
103
+ }
104
+ }
105
+ }
106
+ }
107
+ return true;
108
+ } }).catch(() => { }).finally(() => { this.cleanup = undefined; });
109
+ let taskRoles;
110
+ try {
111
+ taskRoles = taskRoleAssociations();
112
+ }
113
+ catch {
114
+ this.warn('Notification task association unavailable; delivering unclassified events');
115
+ }
37
116
  for (const root of this.roots)
38
117
  for (const roleId of this.names(root)) {
39
118
  const roleDir = join(root, roleId), dir = join(roleDir, '.conversation');
@@ -108,7 +187,7 @@ export class FleetNotificationProducer {
108
187
  if (['message.chunk', 'message.replace'].includes(event.kind) && event.promptId && event.messageId && current.prompts.includes(prompt))
109
188
  current.messages[prompt] = event.messageId;
110
189
  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)}` }))
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)}` }))
112
191
  break;
113
192
  current.prompts = current.prompts.filter(p => p !== prompt);
114
193
  delete current.messages[prompt];
@@ -125,5 +204,5 @@ export class FleetNotificationProducer {
125
204
  }
126
205
  }
127
206
  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())); }
207
+ async close() { clearInterval(this.timer); await this.cleanup; await Promise.all([...this.queues.values()].map(q => q.close())); }
129
208
  }
@@ -3,6 +3,7 @@ export interface NotificationInput {
3
3
  title: string;
4
4
  body: string;
5
5
  url: string;
6
+ taskId?: string | null;
6
7
  }
7
8
  export interface ProducerConfig {
8
9
  /** Service base URL. With a gateway credential it is the server gateway's notifications route. */
@@ -29,6 +30,7 @@ export declare class NotificationOutbox {
29
30
  cursor?: number;
30
31
  checkpoint?: unknown;
31
32
  private running?;
33
+ private retired;
32
34
  private timer;
33
35
  constructor(file: string, config: ProducerConfig, project: (value: unknown, id: string) => Promise<NotificationInput>, warn?: (message: string) => void);
34
36
  private persist;
@@ -37,6 +39,8 @@ export declare class NotificationOutbox {
37
39
  enqueue(id: string, value: unknown, cursor?: number | undefined): boolean;
38
40
  drain(): Promise<void>;
39
41
  private drainOnce;
42
+ /** Wait for the current send before clearing queued sends and retiring its target. */
43
+ retire(): Promise<void>;
40
44
  close(): Promise<void>;
41
45
  }
42
46
  export {};
@@ -66,6 +66,7 @@ export class NotificationOutbox {
66
66
  cursor;
67
67
  checkpoint;
68
68
  running;
69
+ retired = false;
69
70
  timer;
70
71
  constructor(file, config, project, warn = () => { }) {
71
72
  this.file = file;
@@ -106,6 +107,8 @@ export class NotificationOutbox {
106
107
  advance(cursor) { this.persist(this.entries, cursor); }
107
108
  saveCheckpoint(checkpoint) { this.persist(this.entries, this.cursor, checkpoint); }
108
109
  enqueue(id, value, cursor = this.cursor) {
110
+ if (this.retired)
111
+ return false;
109
112
  if (this.entries.some(e => e.id === id)) {
110
113
  if (cursor !== this.cursor)
111
114
  this.advance(cursor);
@@ -117,10 +120,14 @@ export class NotificationOutbox {
117
120
  return true;
118
121
  }
119
122
  drain() {
123
+ if (this.retired)
124
+ return Promise.resolve();
120
125
  return this.running ??= this.drainOnce().finally(() => { this.running = undefined; });
121
126
  }
122
127
  async drainOnce() {
123
128
  for (const entry of [...this.entries].slice(0, 32)) {
129
+ if (this.retired)
130
+ break;
124
131
  try {
125
132
  const value = entry.payload ?? await this.project(entry.value, entry.id);
126
133
  // Freeze the canonical projection before any network attempt. A crash
@@ -145,5 +152,7 @@ export class NotificationOutbox {
145
152
  }
146
153
  }
147
154
  }
155
+ /** Wait for the current send before clearing queued sends and retiring its target. */
156
+ async retire() { this.retired = true; clearInterval(this.timer); await this.running; this.persist([]); }
148
157
  async close() { clearInterval(this.timer); await this.running; }
149
158
  }
@@ -0,0 +1,8 @@
1
+ import { type ProducerConfig } from './outbox.js';
2
+ /** Queue only after the owning lifecycle has established deletion, never on a failed inventory read. */
3
+ export declare function retireNotificationTarget(url: string, config?: ProducerConfig | undefined, dir?: string, request?: typeof fetch): Promise<void>;
4
+ /** Persisted retries also run from the ordinary web notification producer after restart. */
5
+ export declare function drainNotificationTargetCleanup(config: ProducerConfig, dir?: string, request?: typeof fetch, options?: {
6
+ retain?: boolean;
7
+ beforeDelete?: (url: string) => Promise<boolean>;
8
+ }): Promise<void>;
@@ -0,0 +1,41 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { replaceFileAtomically } from '../atomic-file.js';
5
+ import { stateRoot } from '../paths.js';
6
+ import { producerConfig } from './outbox.js';
7
+ const cleanupDir = () => join(stateRoot(), 'notifications', 'removed-targets');
8
+ /** Queue only after the owning lifecycle has established deletion, never on a failed inventory read. */
9
+ export async function retireNotificationTarget(url, config = producerConfig(), dir = cleanupDir(), request = fetch) {
10
+ if (!config)
11
+ return;
12
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
13
+ replaceFileAtomically(join(dir, createHash('sha256').update(url).digest('hex') + '.json'), JSON.stringify({ url }) + '\n', 0o600);
14
+ await drainNotificationTargetCleanup(config, dir, request, { retain: true });
15
+ }
16
+ /** Persisted retries also run from the ordinary web notification producer after restart. */
17
+ export async function drainNotificationTargetCleanup(config, dir = cleanupDir(), request = fetch, options = {}) {
18
+ if (!existsSync(dir))
19
+ return;
20
+ for (const file of readdirSync(dir).filter(file => /^[a-f0-9]{64}\.json$/.test(file)).slice(0, 32)) {
21
+ try {
22
+ const path = join(dir, file), value = JSON.parse(readFileSync(path, 'utf8'));
23
+ if (typeof value.url !== 'string' || !value.url.startsWith('/fleet/chats?'))
24
+ continue;
25
+ if (options.beforeDelete && !(await options.beforeDelete(value.url))) {
26
+ rmSync(path, { force: true });
27
+ continue;
28
+ }
29
+ const response = await request(config.origin + '/api/v1/delete-target', { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(10000),
30
+ headers: { ...(config.gatewayCredential ? { 'X-Ours-Api-Token': config.gatewayCredential, 'X-Ours-Notifications-Producer': config.token } : { Authorization: `Bearer ${config.token}` }), 'Content-Type': 'application/json' }, body: JSON.stringify(value) });
31
+ await response.body?.cancel();
32
+ if (!response.ok)
33
+ break;
34
+ if (!options.retain)
35
+ rmSync(path, { force: true });
36
+ }
37
+ catch {
38
+ break; /* No credentials or response bodies are included in logs. */
39
+ }
40
+ }
41
+ }
package/dist/ops.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { retireNotificationTarget } from './notifications/target-cleanup.js';
1
2
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, unlinkSync, } from 'node:fs';
2
3
  import { join } from 'node:path';
3
4
  import { randomUUID, createHash } from 'node:crypto';
@@ -257,6 +258,7 @@ export async function rmRole(cfg, name, deps) {
257
258
  rmSync(agentDir(name), { recursive: true, force: true });
258
259
  if (generatedSource && existsSync(role.sourceFile)) {
259
260
  unlinkSync(role.sourceFile);
261
+ await retireNotificationTarget(`/fleet/chats?chat=${encodeURIComponent(name)}`).catch(() => { });
260
262
  deps.log(`removed ${role.sourceFile}`);
261
263
  }
262
264
  deps.log(`removed '${name}' (its ours identity is left intact)`);
@@ -272,6 +272,8 @@ export declare class AcpSession implements AgentSession {
272
272
  * (`queuePrompt({ interrupt: true })`) needs the typed error, because only it
273
273
  * still owes an undelivered message a replay.
274
274
  */
275
+ readonly liveVoiceSupported = true;
276
+ interruptPrompt(generation: string, promptId: string): Promise<InterruptOutcome>;
275
277
  interrupt(source?: TurnCancellationSource): Promise<InterruptOutcome>;
276
278
  private cancelActive;
277
279
  /**
@@ -763,6 +763,8 @@ export class AcpSession {
763
763
  return existing;
764
764
  if (command.expectedSessionGeneration !== undefined && command.expectedSessionGeneration !== this.conversationSnapshot().sessionGeneration)
765
765
  throw new Error('session_changed: Agent session changed. Record a new voice message.');
766
+ if (command.requireIdle && (this.queueDepth > 0 || this.conversationSnapshot().readiness !== 'idle'))
767
+ throw new Error('voice_busy: Agent is already working. Wait for the current turn.');
766
768
  const queued = await this.queuePrompt(command.text, {
767
769
  origin: { kind: 'owner-admin-console', commandId: command.commandId },
768
770
  actor: { browserSession: command.actorBrowserSession },
@@ -795,6 +797,14 @@ export class AcpSession {
795
797
  * (`queuePrompt({ interrupt: true })`) needs the typed error, because only it
796
798
  * still owes an undelivered message a replay.
797
799
  */
800
+ liveVoiceSupported = true;
801
+ async interruptPrompt(generation, promptId) {
802
+ if (generation !== this.sessionGeneration)
803
+ throw new Error('session_changed: Agent session changed.');
804
+ if (this.activeTurn?.id !== promptId)
805
+ return { state: 'settled' };
806
+ return this.interrupt('local-console');
807
+ }
798
808
  async interrupt(source = 'local-console') {
799
809
  try {
800
810
  await this.cancelActive(source);
@@ -75,6 +75,8 @@ export declare class RoleTurnArbiter implements AgentSession {
75
75
  conversationSnapshot(): ConversationSnapshot;
76
76
  subscribeConversation(listener: (event: ConversationEventV1) => void): () => void;
77
77
  submitPromptBrowser(command: SubmitPromptCommand): Promise<PromptReceipt>;
78
+ get liveVoiceSupported(): boolean;
79
+ interruptPrompt(generation: string, promptId: string): Promise<InterruptOutcome>;
78
80
  interrupt(source?: TurnCancellationSource): Promise<InterruptOutcome>;
79
81
  respondPermission(permissionId: string, optionId: string): boolean;
80
82
  respondPermissionV2(permissionId: string, optionId: string, sessionGeneration: string): 'accepted' | 'stale';
@@ -177,6 +177,14 @@ export class RoleTurnArbiter {
177
177
  return this.exclusive(() => { if (this.retiring)
178
178
  throw new Error('Chat is closing'); return this.session.submitPromptBrowser(command); });
179
179
  }
180
+ get liveVoiceSupported() { return this.session.liveVoiceSupported === true; }
181
+ interruptPrompt(generation, promptId) {
182
+ return this.exclusive(async () => {
183
+ if (!this.session.interruptPrompt)
184
+ throw new Error('targeted voice interrupt is unavailable');
185
+ return interruptOutcome(await this.session.interruptPrompt(generation, promptId));
186
+ });
187
+ }
180
188
  interrupt(source = 'local-console') {
181
189
  return this.exclusive(async () => interruptOutcome(await this.session.interrupt(source)));
182
190
  }
@@ -69,6 +69,8 @@ export declare class CodexAppServerSession implements AgentSession {
69
69
  snapshot(): SessionSnapshot;
70
70
  queuePrompt(text: string, options?: SubmitPromptOptions): Promise<QueuedPrompt>;
71
71
  submitPrompt(text: string, options?: SubmitPromptOptions): Promise<TurnResult>;
72
+ readonly liveVoiceSupported = true;
73
+ interruptPrompt(generation: string, promptId: string): Promise<InterruptOutcome>;
72
74
  interrupt(source?: TurnCancellationSource): Promise<InterruptOutcome>;
73
75
  respondPermission(permissionId: string, optionId: string): boolean;
74
76
  respondPermissionV2(permissionId: string, optionId: string, sessionGeneration: string): 'accepted' | 'stale';