@ours.network/fleet 0.18.1 → 0.19.0-nightly.2

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 (79) hide show
  1. package/README.md +57 -91
  2. package/dist/application/fleet-query-service.js +0 -12
  3. package/dist/application/role-creation-service.js +10 -7
  4. package/dist/application/types.d.ts +0 -11
  5. package/dist/briefing.js +6 -15
  6. package/dist/build-info.json +5 -5
  7. package/dist/cli.js +12 -37
  8. package/dist/config.d.ts +8 -6
  9. package/dist/config.js +46 -30
  10. package/dist/creation.d.ts +38 -22
  11. package/dist/creation.js +111 -24
  12. package/dist/docs.d.ts +1 -1
  13. package/dist/docs.js +44 -95
  14. package/dist/doctor.d.ts +5 -1
  15. package/dist/doctor.js +18 -11
  16. package/dist/fleet-proxy.d.ts +0 -5
  17. package/dist/harness/acp-agent.js +6 -11
  18. package/dist/harness/claude-code.js +11 -200
  19. package/dist/harness/codex.d.ts +1 -4
  20. package/dist/harness/codex.js +12 -70
  21. package/dist/harness/types.d.ts +4 -54
  22. package/dist/loops/manager.d.ts +1 -30
  23. package/dist/loops/manager.js +6 -69
  24. package/dist/loops/state.d.ts +0 -18
  25. package/dist/loops/state.js +0 -4
  26. package/dist/monitor.js +1 -1
  27. package/dist/ops.d.ts +3 -0
  28. package/dist/ops.js +8 -3
  29. package/dist/owner-channel/attachments.d.ts +25 -2
  30. package/dist/owner-channel/attachments.js +61 -5
  31. package/dist/owner-channel/channel.d.ts +29 -30
  32. package/dist/owner-channel/channel.js +291 -291
  33. package/dist/owner-channel/commands.js +89 -0
  34. package/dist/owner-channel/message-recovery.d.ts +25 -0
  35. package/dist/owner-channel/message-recovery.js +114 -0
  36. package/dist/owner-channel/notices.d.ts +0 -7
  37. package/dist/owner-channel/notices.js +0 -9
  38. package/dist/owner-channel/ours-client.d.ts +148 -0
  39. package/dist/owner-channel/ours-client.js +231 -0
  40. package/dist/rooms-tasks/cli.d.ts +4 -0
  41. package/dist/rooms-tasks/cli.js +565 -0
  42. package/dist/rooms-tasks/config.d.ts +15 -0
  43. package/dist/rooms-tasks/config.js +171 -0
  44. package/dist/rooms-tasks/cowork-adapter.d.ts +80 -0
  45. package/dist/rooms-tasks/cowork-adapter.js +48 -0
  46. package/dist/rooms-tasks/index.d.ts +7 -0
  47. package/dist/rooms-tasks/index.js +7 -0
  48. package/dist/rooms-tasks/room-state.d.ts +22 -0
  49. package/dist/rooms-tasks/room-state.js +117 -0
  50. package/dist/rooms-tasks/task-state.d.ts +31 -0
  51. package/dist/rooms-tasks/task-state.js +173 -0
  52. package/dist/rooms-tasks/templates.d.ts +6 -0
  53. package/dist/rooms-tasks/templates.js +80 -0
  54. package/dist/rooms-tasks/types.d.ts +153 -0
  55. package/dist/rooms-tasks/types.js +20 -0
  56. package/dist/runner.d.ts +0 -48
  57. package/dist/runner.js +94 -236
  58. package/dist/session/acp.d.ts +0 -104
  59. package/dist/session/acp.js +10 -213
  60. package/dist/session/conversation-normalizer.d.ts +0 -6
  61. package/dist/session/conversation-normalizer.js +10 -153
  62. package/dist/session/conversation-types.d.ts +4 -23
  63. package/dist/session/types.d.ts +0 -35
  64. package/dist/spawn.js +26 -33
  65. package/dist/supervisor/systemd.js +29 -2
  66. package/dist/watchdog/briefing.js +0 -7
  67. package/dist/watchdog/run.js +3 -3
  68. package/dist/web-app/assets/{TerminalView-C_G1ID2P.js → TerminalView-BAVk1Bot.js} +1 -1
  69. package/dist/web-app/assets/{index-BCBK78hw.js → index-C3S-xFRU.js} +5 -5
  70. package/dist/web-app/index.html +1 -1
  71. package/dist/worklog.d.ts +1 -7
  72. package/dist/worklog.js +39 -191
  73. package/package.json +3 -1
  74. package/dist/model-env.d.ts +0 -71
  75. package/dist/model-env.js +0 -106
  76. package/dist/owner-channel/mcp.d.ts +0 -24
  77. package/dist/owner-channel/mcp.js +0 -145
  78. package/dist/session/activity.d.ts +0 -31
  79. package/dist/session/activity.js +0 -48
@@ -142,6 +142,95 @@ export const ownerCommands = [
142
142
  name: 'version', summary: 'report the fleet version',
143
143
  execute: noArgs('/version', async (ctx) => ctx.reply(`ℹ️ ours-fleet ${ctx.version}`)),
144
144
  },
145
+ {
146
+ name: 'tasks', usage: '/tasks [state]',
147
+ summary: 'list tasks (optionally filter by state)',
148
+ execute: async (ctx, args) => {
149
+ const { listTasks } = await import('../rooms-tasks/task-state.js');
150
+ const stateFilter = args?.trim() || undefined;
151
+ const tasks = listTasks(stateFilter && stateFilter !== 'all' ? { state: stateFilter } : undefined);
152
+ if (!tasks.length)
153
+ return ctx.reply('📋 No tasks.');
154
+ const lines = tasks.map(t => {
155
+ const blocked = t.blocked ? ` [BLOCKED: ${t.blocked.reason}]` : '';
156
+ return `${t.task_id} ${t.state}${blocked} ${t.title}`;
157
+ });
158
+ await ctx.reply(tail(`📋 Tasks:\n${lines.join('\n')}`, REPLY_MAX_CHARS));
159
+ },
160
+ },
161
+ {
162
+ name: 'task', usage: '/task <id>',
163
+ summary: 'show task details',
164
+ execute: async (ctx, args) => {
165
+ if (!args)
166
+ throw new OwnerCommandUsageError('usage: /task <id>');
167
+ const { getTask } = await import('../rooms-tasks/task-state.js');
168
+ try {
169
+ const t = getTask(args.trim());
170
+ const lines = [
171
+ `📋 Task: ${t.task_id}`,
172
+ `Title: ${t.title}`,
173
+ `State: ${t.state}${t.blocked ? ` [BLOCKED: ${t.blocked.reason}]` : ''}`,
174
+ ...(t.template ? [`Template: ${t.template.name}@${t.template.version}`] : []),
175
+ ...(t.room_id ? [`Room: ${t.room_id}`] : []),
176
+ `Origin: ${t.origin.type}`,
177
+ `Created: ${t.created_at}`,
178
+ ];
179
+ await ctx.reply(lines.join('\n'));
180
+ }
181
+ catch (e) {
182
+ await ctx.reply(`⚠️ ${e instanceof Error ? e.message : String(e)}`);
183
+ }
184
+ },
185
+ },
186
+ {
187
+ name: 'rooms', summary: 'list rooms',
188
+ execute: noArgs('/rooms', async (ctx) => {
189
+ const { listRoomRecords } = await import('../rooms-tasks/room-state.js');
190
+ const rooms = listRoomRecords();
191
+ if (!rooms.length)
192
+ return ctx.reply('🏠 No rooms.');
193
+ const lines = rooms.map(r => `${r.room_id} ${r.state} ${r.room_name}${r.task_id ? ` (task: ${r.task_id})` : ''}`);
194
+ await ctx.reply(tail(`🏠 Rooms:\n${lines.join('\n')}`, REPLY_MAX_CHARS));
195
+ }),
196
+ },
197
+ {
198
+ name: 'room', usage: '/room <id>',
199
+ summary: 'show room details',
200
+ execute: async (ctx, args) => {
201
+ if (!args)
202
+ throw new OwnerCommandUsageError('usage: /room <id>');
203
+ const { getRoomRecord } = await import('../rooms-tasks/room-state.js');
204
+ const r = getRoomRecord(args.trim());
205
+ if (!r)
206
+ return ctx.reply(`⚠️ room not found: ${args.trim()}`);
207
+ const lines = [
208
+ `🏠 Room: ${r.room_id}`,
209
+ `Name: ${r.room_name}`,
210
+ `State: ${r.state}`,
211
+ `Saga: ${r.saga.phase} (step ${r.saga.step_index})`,
212
+ ...(r.task_id ? [`Task: ${r.task_id}`] : []),
213
+ ...(r.provisioning_detail ? [`Detail: ${r.provisioning_detail}`] : []),
214
+ ...(r.saga.error ? [`Error: ${r.saga.error}`] : []),
215
+ `Created: ${r.created_at}`,
216
+ ];
217
+ await ctx.reply(lines.join('\n'));
218
+ },
219
+ },
220
+ {
221
+ name: 'templates', summary: 'list available room templates',
222
+ execute: noArgs('/templates', async (ctx) => {
223
+ const { listTemplates } = await import('../rooms-tasks/templates.js');
224
+ const templates = listTemplates({});
225
+ if (!templates.length)
226
+ return ctx.reply('📐 No templates.');
227
+ const lines = templates.map(t => {
228
+ const tag = t.builtin ? ' (built-in)' : '';
229
+ return `${t.name}@${t.version}${tag} ${t.description}`;
230
+ });
231
+ await ctx.reply(`📐 Templates:\n${lines.join('\n')}`);
232
+ }),
233
+ },
145
234
  ];
146
235
  /** Trimmed slash-prefixed text is a command attempt and is never forwarded. */
147
236
  export const isOwnerCommandText = (text) => text.trim().startsWith('/');
@@ -0,0 +1,25 @@
1
+ export interface PendingMessageClaim {
2
+ wireId: string;
3
+ seq: number;
4
+ claimedAt: number;
5
+ }
6
+ /**
7
+ * Body-free crash journal for the SQLite getMessages read boundary.
8
+ *
9
+ * A claim lands before getMessages marks its exact oldest-first batch read.
10
+ * After a crash, getHistoryItem can therefore recover the body by wire ID
11
+ * without fleet ever duplicating message plaintext in its own state.
12
+ */
13
+ export declare class MessageRecoveryState {
14
+ private readonly path;
15
+ private readonly limit;
16
+ private pending;
17
+ private corrupt;
18
+ constructor(path: string, limit?: number);
19
+ integrity(): boolean;
20
+ list(): PendingMessageClaim[];
21
+ claim(items: PendingMessageClaim[]): void;
22
+ pruneHandled(handled: (wireId: string) => boolean): number;
23
+ private assertHealthy;
24
+ private persist;
25
+ }
@@ -0,0 +1,114 @@
1
+ import { chmodSync, existsSync, readFileSync } from 'node:fs';
2
+ import { replaceFileAtomically } from '../atomic-file.js';
3
+ const MAX_PENDING_MESSAGES = 5_000;
4
+ const MAX_WIRE_ID_CHARS = 1_024;
5
+ /**
6
+ * Body-free crash journal for the SQLite getMessages read boundary.
7
+ *
8
+ * A claim lands before getMessages marks its exact oldest-first batch read.
9
+ * After a crash, getHistoryItem can therefore recover the body by wire ID
10
+ * without fleet ever duplicating message plaintext in its own state.
11
+ */
12
+ export class MessageRecoveryState {
13
+ path;
14
+ limit;
15
+ pending = [];
16
+ corrupt = false;
17
+ constructor(path, limit = MAX_PENDING_MESSAGES) {
18
+ this.path = path;
19
+ this.limit = limit;
20
+ if (!existsSync(path))
21
+ return;
22
+ try {
23
+ const raw = JSON.parse(readFileSync(path, 'utf8'));
24
+ if (raw.version !== 1 || !Array.isArray(raw.pending)
25
+ || raw.pending.length > limit || !raw.pending.every(validClaim)
26
+ || new Set(raw.pending.map(item => item.wireId)).size !== raw.pending.length)
27
+ throw new Error('invalid message recovery state');
28
+ this.pending = raw.pending.map(item => ({ ...item }));
29
+ chmodSync(path, 0o600);
30
+ }
31
+ catch {
32
+ this.corrupt = true;
33
+ this.pending = [];
34
+ try {
35
+ chmodSync(path, 0o600);
36
+ }
37
+ catch { /* retain the evidence */ }
38
+ }
39
+ }
40
+ integrity() { return !this.corrupt; }
41
+ list() {
42
+ this.assertHealthy();
43
+ return this.pending.map(item => ({ ...item }));
44
+ }
45
+ claim(items) {
46
+ this.assertHealthy();
47
+ if (!items.length)
48
+ return;
49
+ if (!items.every(validClaim))
50
+ throw new Error('invalid message recovery claim');
51
+ const next = this.pending.map(item => ({ ...item }));
52
+ const byWire = new Map(next.map(item => [item.wireId, item]));
53
+ for (const item of items) {
54
+ const existing = byWire.get(item.wireId);
55
+ if (existing) {
56
+ if (existing.seq !== item.seq)
57
+ throw new Error('message recovery wire ID changed sequence');
58
+ continue;
59
+ }
60
+ if (next.length >= this.limit)
61
+ throw new Error('too many pending message recoveries');
62
+ const copy = { ...item };
63
+ next.push(copy);
64
+ byWire.set(copy.wireId, copy);
65
+ }
66
+ if (next.length === this.pending.length)
67
+ return;
68
+ const previous = this.pending;
69
+ this.pending = next;
70
+ try {
71
+ this.persist();
72
+ }
73
+ catch (error) {
74
+ this.pending = previous;
75
+ throw error;
76
+ }
77
+ }
78
+ pruneHandled(handled) {
79
+ this.assertHealthy();
80
+ const next = this.pending.filter(item => !handled(item.wireId));
81
+ const removed = this.pending.length - next.length;
82
+ if (!removed)
83
+ return 0;
84
+ const previous = this.pending;
85
+ this.pending = next;
86
+ try {
87
+ this.persist();
88
+ }
89
+ catch (error) {
90
+ this.pending = previous;
91
+ throw error;
92
+ }
93
+ return removed;
94
+ }
95
+ assertHealthy() {
96
+ if (this.corrupt)
97
+ throw new Error('message recovery state is corrupt');
98
+ }
99
+ persist() {
100
+ replaceFileAtomically(this.path, JSON.stringify({
101
+ version: 1, pending: this.pending,
102
+ }) + '\n', 0o600);
103
+ chmodSync(this.path, 0o600);
104
+ }
105
+ }
106
+ function validClaim(value) {
107
+ if (!value || typeof value !== 'object')
108
+ return false;
109
+ const item = value;
110
+ return typeof item.wireId === 'string' && item.wireId.length >= 1
111
+ && Array.from(item.wireId).length <= MAX_WIRE_ID_CHARS
112
+ && Number.isSafeInteger(item.seq) && item.seq >= 1
113
+ && Number.isSafeInteger(item.claimedAt) && item.claimedAt >= 0;
114
+ }
@@ -22,13 +22,6 @@ export declare const ownerNotices: {
22
22
  receivedStarted: () => string;
23
23
  receivedQueued: (queuedBehind: number) => string;
24
24
  receivedInterrupting: () => string;
25
- /**
26
- * The honest answer when the agent is mid-task and pre-empting it would have
27
- * corrupted the conversation. Says "not started yet" rather than borrowing
28
- * `receivedInterrupting`'s claim that something was cancelled for this
29
- * request.
30
- */
31
- receivedDeferred: () => string;
32
25
  status: (role: string, snapshot: SessionSnapshot) => string;
33
26
  interrupted: (role: string) => string;
34
27
  /** The turn IS cancelled — say how, without implying the owner must retry. */
@@ -26,15 +26,6 @@ export const ownerNotices = {
26
26
  receivedInterrupting: () => "ℹ️ Message received. The agent's previous task was interrupted to prioritize "
27
27
  + 'this request, and it is now working on a response. '
28
28
  + 'The response will arrive in this channel when ready.',
29
- /**
30
- * The honest answer when the agent is mid-task and pre-empting it would have
31
- * corrupted the conversation. Says "not started yet" rather than borrowing
32
- * `receivedInterrupting`'s claim that something was cancelled for this
33
- * request.
34
- */
35
- receivedDeferred: () => 'ℹ️ Message received and held. The agent is in the middle of a task that '
36
- + 'cannot be interrupted safely; this request starts as soon as that work '
37
- + 'reaches a stopping point. The response will arrive in this channel when ready.',
38
29
  status: (role, snapshot) => `📊 ${role} status: ${snapshot.readiness}; session is ${snapshot.alive ? 'online' : 'offline'}.`,
39
30
  interrupted: (role) => `🛑 Interrupt sent to ${role}'s active turn.`,
40
31
  /** The turn IS cancelled — say how, without implying the owner must retry. */
@@ -0,0 +1,148 @@
1
+ import { OursClient, type AttachOursClientOptions, type NotificationEvent } from '@ours.network/sdk/client';
2
+ /** Any failure of a daemon operation. Never carries a message body or a token. */
3
+ export declare class OursDaemonError extends Error {
4
+ }
5
+ /**
6
+ * The daemon accepted the call and answered "not sent". The MCP surface reported
7
+ * exactly these verdicts as tool errors, so they must keep throwing here: the
8
+ * owner channel books a resolved send as delivered, and a silently-swallowed
9
+ * refusal would be recorded as a delivered message that never left the host.
10
+ */
11
+ export declare class OursSendRefusedError extends OursDaemonError {
12
+ }
13
+ type Res<M extends keyof OursClient> = OursClient[M] extends (...args: never[]) => infer R ? Awaited<R> : never;
14
+ export type OursContactsView = Res<'listContacts'>;
15
+ export type OursInviteResult = Res<'generateInvite'>;
16
+ export type OursAddContactResult = Res<'addContact'>;
17
+ export type OursIncomingMessage = Res<'listIncomingMessages'>[number];
18
+ export type OursMessagesPayload = Res<'getMessages'>;
19
+ export type OursInboundMessage = OursMessagesPayload['messages'][number];
20
+ export type OursHistoryMessage = NonNullable<Res<'getHistoryItem'>>;
21
+ export type OursIncomingFile = Res<'listIncomingFiles'>[number];
22
+ export type OursRetrievedFiles = Res<'getFiles'>;
23
+ export type OursRetrievedFile = OursRetrievedFiles['files'][number];
24
+ export type OursHistoryFile = NonNullable<Res<'getFileInfo'>>;
25
+ export type OursNotificationEvent = NotificationEvent;
26
+ export declare class OursWatchDeadlineError extends OursDaemonError {
27
+ }
28
+ /**
29
+ * The daemon operations the owner channel needs, one typed method each.
30
+ *
31
+ * This interface deliberately has no generic `callTool(name, args): unknown`
32
+ * escape hatch. The legacy MCP connector answered every
33
+ * tool with `{content:[{type:'text',...}]}` and no `structuredContent`, the
34
+ * transport fell back to returning the daemon's English sentence — which the
35
+ * channel then pattern-matched (an invite blob sliced out of a prose sentence,
36
+ * a bind conflict detected with /currently bound to another live session/i).
37
+ * With no untyped result there is nothing left to pattern-match.
38
+ */
39
+ export interface OursOps {
40
+ /** Prepare the transport. Must be called before any operation. */
41
+ start(): Promise<void>;
42
+ /** Bind this session's identity. Throws `BOUND_ELSEWHERE` when it is held live. */
43
+ bindIdentity(name: string): Promise<void>;
44
+ listContacts(): Promise<OursContactsView>;
45
+ generateInvite(name?: string): Promise<OursInviteResult>;
46
+ addContact(a: {
47
+ invite: string;
48
+ name?: string;
49
+ }): Promise<OursAddContactResult>;
50
+ listIncomingMessages(): Promise<OursIncomingMessage[]>;
51
+ getMessages(limit: number): Promise<OursMessagesPayload>;
52
+ getHistoryItem(wireId: string): Promise<OursHistoryMessage | null>;
53
+ watchNotifications(identity: string, options?: {
54
+ since?: number | 'tip';
55
+ signal?: AbortSignal;
56
+ }): AsyncGenerator<OursNotificationEvent, void, undefined>;
57
+ listIncomingFiles(): Promise<OursIncomingFile[]>;
58
+ getFileInfo(wireId: string): Promise<OursHistoryFile | null>;
59
+ getFiles(wireIds: string[]): Promise<OursRetrievedFiles>;
60
+ /**
61
+ * The bytes of an already-retrieved file. Transport only — the caller owns
62
+ * where they land, so path safety stays with the attachment code that already
63
+ * enforces it (`writeRecoveredAttachment`).
64
+ */
65
+ fetchFile(wireId: string): Promise<Uint8Array>;
66
+ sendMessage(a: {
67
+ contact: string;
68
+ text: string;
69
+ replyToWireId?: string;
70
+ }): Promise<void>;
71
+ sendFile(a: {
72
+ contact: string;
73
+ path: string;
74
+ filename: string;
75
+ replyToWireId?: string;
76
+ }): Promise<void>;
77
+ /** Release the daemon lease and stop. Never throws. */
78
+ close(): Promise<void>;
79
+ }
80
+ /**
81
+ * The typed error code of a daemon operation, or undefined when the failure was
82
+ * not one (transport, abort, programming error). `instanceof` is checked first;
83
+ * the structural fallback keeps a duplicated SDK copy in a consumer's tree from
84
+ * silently demoting a real daemon verdict to "unknown transport failure".
85
+ */
86
+ export declare function oursErrorCode(error: unknown): string | undefined;
87
+ /** The identity is bound by another live session; a predecessor may still be releasing it. */
88
+ export declare const OURS_BOUND_ELSEWHERE = "BOUND_ELSEWHERE";
89
+ export interface OursSdkClientDeps {
90
+ /** Test seam; production uses the SDK's coherence-checking application attach path. */
91
+ attachClient?(options: AttachOursClientOptions): OursClient | Promise<OursClient>;
92
+ /** Underlying transport and deadline seams for deterministic half-open tests. */
93
+ fetch?: typeof globalThis.fetch;
94
+ notificationRequestDeadlineMs?: number;
95
+ readFile?(path: string): Promise<Uint8Array>;
96
+ }
97
+ /**
98
+ * The owner-channel's daemon client: one `OursClient` over the local ours HTTP
99
+ * API, owning exactly one identity binding.
100
+ *
101
+ * Lease lifetime. The lease token IS the session, so each channel instance mints
102
+ * its own and hands it back in `close()`. That replaces the connector proxy's
103
+ * shell-PID fence, which existed because a supervised attempt had to make its
104
+ * lease reclaimable while the supervisor itself stayed alive: an explicit
105
+ * release does that deterministically, and `clientPid` still covers the case
106
+ * where the whole supervisor dies without unwinding.
107
+ */
108
+ export declare class OursSdkClient implements OursOps {
109
+ private readonly env;
110
+ private readonly log;
111
+ private readonly deps;
112
+ private client?;
113
+ private readonly leaseToken;
114
+ constructor(env?: Record<string, string>, log?: (line: string) => void, deps?: OursSdkClientDeps);
115
+ start(): Promise<void>;
116
+ bindIdentity(name: string): Promise<void>;
117
+ listContacts(): Promise<OursContactsView>;
118
+ generateInvite(name?: string): Promise<OursInviteResult>;
119
+ addContact(a: {
120
+ invite: string;
121
+ name?: string;
122
+ }): Promise<OursAddContactResult>;
123
+ listIncomingMessages(): Promise<OursIncomingMessage[]>;
124
+ getMessages(limit: number): Promise<OursMessagesPayload>;
125
+ getHistoryItem(wireId: string): Promise<OursHistoryMessage | null>;
126
+ watchNotifications(identity: string, options?: {
127
+ since?: number | 'tip';
128
+ signal?: AbortSignal;
129
+ }): AsyncGenerator<OursNotificationEvent, void, undefined>;
130
+ listIncomingFiles(): Promise<OursIncomingFile[]>;
131
+ getFileInfo(wireId: string): Promise<OursHistoryFile | null>;
132
+ getFiles(wireIds: string[]): Promise<OursRetrievedFiles>;
133
+ fetchFile(wireId: string): Promise<Uint8Array>;
134
+ sendMessage(a: {
135
+ contact: string;
136
+ text: string;
137
+ replyToWireId?: string;
138
+ }): Promise<void>;
139
+ sendFile(a: {
140
+ contact: string;
141
+ path: string;
142
+ filename: string;
143
+ replyToWireId?: string;
144
+ }): Promise<void>;
145
+ close(): Promise<void>;
146
+ private ops;
147
+ }
148
+ export {};
@@ -0,0 +1,231 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { extname } from 'node:path';
4
+ import { OursError, attachOursClient, } from '@ours.network/sdk/client';
5
+ /** Any failure of a daemon operation. Never carries a message body or a token. */
6
+ export class OursDaemonError extends Error {
7
+ }
8
+ /**
9
+ * The daemon accepted the call and answered "not sent". The MCP surface reported
10
+ * exactly these verdicts as tool errors, so they must keep throwing here: the
11
+ * owner channel books a resolved send as delivered, and a silently-swallowed
12
+ * refusal would be recorded as a delivered message that never left the host.
13
+ */
14
+ export class OursSendRefusedError extends OursDaemonError {
15
+ }
16
+ // The daemon normally returns a quiet long-poll within 25 seconds. Keep the
17
+ // client-side fence comfortably above it so ordinary quiet periods do not
18
+ // recycle a healthy stream, while a half-open socket still heals on its own.
19
+ const NOTIFICATION_REQUEST_DEADLINE_MS = 120_000;
20
+ // SDK sendFile({path}) inferred these common types before reading the path in
21
+ // the daemon process. Staged uploads move that read into fleet, so preserve the
22
+ // same advertised MIME rather than silently turning every attachment into an
23
+ // octet stream. Importing the SDK root just for its helper would also pull the
24
+ // daemon runtime into this client-only process.
25
+ const FILE_MIME_BY_EXTENSION = {
26
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
27
+ '.webp': 'image/webp', '.svg': 'image/svg+xml', '.bmp': 'image/bmp', '.ico': 'image/x-icon',
28
+ '.pdf': 'application/pdf', '.txt': 'text/plain', '.md': 'text/markdown',
29
+ '.json': 'application/json', '.csv': 'text/csv', '.html': 'text/html',
30
+ '.xml': 'application/xml', '.zip': 'application/zip', '.gz': 'application/gzip',
31
+ '.tar': 'application/x-tar', '.mp3': 'audio/mpeg', '.wav': 'audio/wav',
32
+ '.mp4': 'video/mp4', '.mov': 'video/quicktime',
33
+ };
34
+ function mimeFromFilename(filename) {
35
+ return FILE_MIME_BY_EXTENSION[extname(filename).toLowerCase()] ?? 'application/octet-stream';
36
+ }
37
+ export class OursWatchDeadlineError extends OursDaemonError {
38
+ }
39
+ function notificationRequest(input) {
40
+ const url = typeof input === 'string' || input instanceof URL ? String(input) : input.url;
41
+ return /\/identities\/[^/]+\/notifications(?:\?|$)/.test(url);
42
+ }
43
+ function notificationDeadlineFetch(fetchImpl, deadlineMs) {
44
+ return async (input, init) => {
45
+ if (!notificationRequest(input))
46
+ return fetchImpl(input, init);
47
+ const ctrl = new AbortController();
48
+ const upstream = init?.signal;
49
+ let rejectFence;
50
+ const fence = new Promise((_resolve, reject) => { rejectFence = reject; });
51
+ const abortUpstream = () => {
52
+ const reason = upstream?.reason
53
+ ?? new DOMException('ours notification request aborted', 'AbortError');
54
+ ctrl.abort(reason);
55
+ // Shutdown must not wait for the deadline when a custom fetch ignores
56
+ // AbortSignal either.
57
+ rejectFence(reason);
58
+ };
59
+ if (upstream?.aborted)
60
+ abortUpstream();
61
+ else
62
+ upstream?.addEventListener('abort', abortUpstream, { once: true });
63
+ const timer = setTimeout(() => {
64
+ const error = new OursWatchDeadlineError('ours notification request deadline exceeded');
65
+ ctrl.abort(error);
66
+ // Do not rely on a custom or half-open fetch implementation to honor
67
+ // AbortSignal: the fence itself must always settle the request.
68
+ rejectFence(error);
69
+ }, deadlineMs);
70
+ timer.unref?.();
71
+ try {
72
+ return await Promise.race([
73
+ fetchImpl(input, { ...init, signal: ctrl.signal }),
74
+ fence,
75
+ ]);
76
+ }
77
+ finally {
78
+ clearTimeout(timer);
79
+ upstream?.removeEventListener('abort', abortUpstream);
80
+ }
81
+ };
82
+ }
83
+ /**
84
+ * The typed error code of a daemon operation, or undefined when the failure was
85
+ * not one (transport, abort, programming error). `instanceof` is checked first;
86
+ * the structural fallback keeps a duplicated SDK copy in a consumer's tree from
87
+ * silently demoting a real daemon verdict to "unknown transport failure".
88
+ */
89
+ export function oursErrorCode(error) {
90
+ if (error instanceof OursError)
91
+ return error.code;
92
+ if (error instanceof Error && error.name === 'OursError') {
93
+ const code = error.code;
94
+ if (typeof code === 'string')
95
+ return code;
96
+ }
97
+ return undefined;
98
+ }
99
+ /** The identity is bound by another live session; a predecessor may still be releasing it. */
100
+ export const OURS_BOUND_ELSEWHERE = 'BOUND_ELSEWHERE';
101
+ /**
102
+ * The owner-channel's daemon client: one `OursClient` over the local ours HTTP
103
+ * API, owning exactly one identity binding.
104
+ *
105
+ * Lease lifetime. The lease token IS the session, so each channel instance mints
106
+ * its own and hands it back in `close()`. That replaces the connector proxy's
107
+ * shell-PID fence, which existed because a supervised attempt had to make its
108
+ * lease reclaimable while the supervisor itself stayed alive: an explicit
109
+ * release does that deterministically, and `clientPid` still covers the case
110
+ * where the whole supervisor dies without unwinding.
111
+ */
112
+ export class OursSdkClient {
113
+ env;
114
+ log;
115
+ deps;
116
+ client;
117
+ leaseToken = `ours-fleet-owner-${process.pid}-${randomUUID()}`;
118
+ constructor(env = {}, log = () => undefined, deps = {}) {
119
+ this.env = env;
120
+ this.log = log;
121
+ this.deps = deps;
122
+ }
123
+ async start() {
124
+ if (this.client)
125
+ return;
126
+ const environment = { ...process.env, ...this.env };
127
+ // SDK 2's supported application path resolves endpoint, state root, and
128
+ // token as one coherent selection, proves the daemon's state root before
129
+ // sending credentials, and only then constructs the client.
130
+ const options = {
131
+ env: environment,
132
+ leaseToken: this.leaseToken,
133
+ clientPid: process.pid,
134
+ fetch: notificationDeadlineFetch(this.deps.fetch ?? globalThis.fetch, this.deps.notificationRequestDeadlineMs ?? NOTIFICATION_REQUEST_DEADLINE_MS),
135
+ };
136
+ this.client = await (this.deps.attachClient?.(options) ?? attachOursClient(options));
137
+ }
138
+ async bindIdentity(name) {
139
+ // force is pinned off: the owner channel never evicts another live session
140
+ // from an identity, it waits for the bounded handoff window and then fails.
141
+ await this.ops().chooseIdentity({ name, force: false });
142
+ }
143
+ async listContacts() {
144
+ return this.ops().listContacts();
145
+ }
146
+ async generateInvite(name) {
147
+ return this.ops().generateInvite(name ? { name } : {});
148
+ }
149
+ async addContact(a) {
150
+ return this.ops().addContact({ invite: a.invite, ...(a.name ? { name: a.name } : {}) });
151
+ }
152
+ async listIncomingMessages() {
153
+ return this.ops().listIncomingMessages();
154
+ }
155
+ async getMessages(limit) {
156
+ return this.ops().getMessages({ limit });
157
+ }
158
+ async getHistoryItem(wireId) {
159
+ return this.ops().getHistoryItem({ wire_id: wireId });
160
+ }
161
+ watchNotifications(identity, options) {
162
+ return this.ops().watchNotifications(identity, options);
163
+ }
164
+ async listIncomingFiles() {
165
+ return this.ops().listIncomingFiles();
166
+ }
167
+ async getFileInfo(wireId) {
168
+ return this.ops().getFileInfo({ wire_id: wireId });
169
+ }
170
+ async getFiles(wireIds) {
171
+ return this.ops().getFiles({ wire_ids: wireIds });
172
+ }
173
+ async fetchFile(wireId) {
174
+ // save_file has no SDK operation on purpose: that daemon route only reports
175
+ // that a too-old connector reached it. The bytes of a retrieved file are
176
+ // already on disk, so read them back and let the caller write them as this
177
+ // process's own OS user.
178
+ return this.ops().fetchFile(wireId);
179
+ }
180
+ async sendMessage(a) {
181
+ const verdict = await this.ops().sendMessage({
182
+ contact: a.contact, text: a.text,
183
+ ...(a.replyToWireId ? { reply_to_wire_id: a.replyToWireId } : {}),
184
+ });
185
+ // Legacy parity: only `refused` was a tool error. `migrating`,
186
+ // `deferred` and `e2e` are accepted-and-queued outcomes that it reported as
187
+ // success, so they must not become failures here.
188
+ if (verdict.kind === 'refused')
189
+ throw new OursSendRefusedError('the daemon refused the message: the contact\'s end-to-end session must be '
190
+ + 're-established after an upgrade; it was not sent and not downgraded');
191
+ }
192
+ async sendFile(a) {
193
+ // The shared daemon may be owned by a different OS process/user and must
194
+ // never be expected to open fleet-private outbox paths. SDK 2 stages bytes
195
+ // read by THIS process, then sends only the opaque upload receipt.
196
+ const bytes = await (this.deps.readFile?.(a.path) ?? readFile(a.path));
197
+ const staged = await this.ops().uploadFile(bytes, {
198
+ filename: a.filename, mime: mimeFromFilename(a.filename),
199
+ });
200
+ const verdict = await this.ops().sendFile({
201
+ contact: a.contact, upload_id: staged.upload_id, filename: a.filename,
202
+ ...(a.replyToWireId ? { reply_to_wire_id: a.replyToWireId } : {}),
203
+ });
204
+ // Legacy parity treated `migrating` as an error for
205
+ // files and as success for messages: files are not auto-queued behind a
206
+ // migration, so "queued" would be a false delivery claim.
207
+ if (verdict.kind === 'refused' || verdict.kind === 'migrating')
208
+ throw new OursSendRefusedError(`the daemon did not send the file (${verdict.kind}): the contact's end-to-end `
209
+ + 'session must be re-established after an upgrade; files are not queued');
210
+ }
211
+ async close() {
212
+ const client = this.client;
213
+ this.client = undefined;
214
+ if (!client)
215
+ return;
216
+ // Handing the lease back is what lets a successor bind this identity without
217
+ // waiting for the supervisor to exit. A failure here is not fatal — the
218
+ // daemon still reclaims the lease when this process dies.
219
+ try {
220
+ await client.releaseLease();
221
+ }
222
+ catch (error) {
223
+ this.log(`lease release failed: ${error?.message ?? String(error)}`);
224
+ }
225
+ }
226
+ ops() {
227
+ if (!this.client)
228
+ throw new OursDaemonError('ours daemon client is not started');
229
+ return this.client;
230
+ }
231
+ }
@@ -0,0 +1,4 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerTemplateCommands(parent: Command, cOpt: (cmd: Command) => Command): void;
3
+ export declare function registerTaskCommands(parent: Command, cOpt: (cmd: Command) => Command): void;
4
+ export declare function registerRoomCommands(parent: Command, cOpt: (cmd: Command) => Command): void;