@ours.network/fleet 2.2.2 → 2.2.4

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
@@ -1863,6 +1863,8 @@ See [Room layouts](docs/room-layouts.md) for YAML examples and CLI commands.
1863
1863
 
1864
1864
  ### Dedicated notification service
1865
1865
 
1866
+ Task room notification registration is attempted before admitting the Owner. If registration fails, room admission and task work continue; unassociated room messages may arrive as ordinary notifications even when mission-room messages are off. Existing-room registration is attempted once per Fleet startup, without automatic registration retries. The installer configures the trusted Fleet producer's mission ownership permission automatically.
1867
+
1866
1868
  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.
1867
1869
 
1868
1870
  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.
@@ -7,39 +7,155 @@ const HISTORICAL_KINDS = ['file.attached', 'fleet.task_created', 'prompt.admitte
7
7
  export function conversationEventVisible(event, sessionGeneration, resumedSessionId) {
8
8
  return event.source !== 'agent_replay' && (event.sessionGeneration === sessionGeneration || (!!resumedSessionId && HISTORICAL_KINDS.includes(event.kind) && event.acpSessionId === resumedSessionId));
9
9
  }
10
- /** Newest-first lines of a file, read in chunks from its end; `visit` returns false to stop. */
11
- async function readLinesBackward(path, visit, signal) {
10
+ // A seek attempt is transactional: its caller discards all collected rows and
11
+ // performs one ordinary scan if a probe or the file identity is no longer safe.
12
+ class SeekFallback extends Error {
13
+ }
14
+ const stamp = (info) => `${info.dev}:${info.ino}:${info.size}:${info.mtimeMs}:${info.ctimeMs}`;
15
+ /** Strict lower bound in an admitted monotonic writer segment, on this FD only. */
16
+ async function seekBefore(file, size, before, signal) {
17
+ const read = async (position, length) => {
18
+ signal?.throwIfAborted();
19
+ const bytes = Buffer.alloc(length);
20
+ const { bytesRead } = await file.read(bytes, 0, length, position);
21
+ signal?.throwIfAborted();
22
+ if (bytesRead !== length)
23
+ throw new SeekFallback('segment changed during seek');
24
+ return bytes;
25
+ };
26
+ if (!size)
27
+ return 0;
28
+ // An active partial append is not a safe boundary, even if the first event
29
+ // would let us skip this whole segment.
30
+ if ((await read(size - 1, 1))[0] !== 10)
31
+ throw new SeekFallback('partial segment');
32
+ const record = async (position) => {
33
+ let start = position, end = position;
34
+ const left = [], right = [];
35
+ while (start > 0) {
36
+ const length = Math.min(4096, start), chunk = await read(start - length, length);
37
+ const newline = chunk.lastIndexOf(10);
38
+ left.push(chunk.subarray(newline + 1));
39
+ start -= length;
40
+ if (newline >= 0) {
41
+ start += newline + 1;
42
+ break;
43
+ }
44
+ }
45
+ while (end < size) {
46
+ const chunk = await read(end, Math.min(4096, size - end)), newline = chunk.indexOf(10);
47
+ right.push(newline < 0 ? chunk : chunk.subarray(0, newline));
48
+ end += newline < 0 ? chunk.length : newline + 1;
49
+ if (newline >= 0)
50
+ break;
51
+ }
52
+ let event;
53
+ try {
54
+ event = JSON.parse(Buffer.concat([...left.reverse(), ...right]).toString('utf8'));
55
+ }
56
+ catch {
57
+ throw new SeekFallback('unreadable seek record');
58
+ }
59
+ if (!event || event.schemaVersion !== 1 || !Number.isSafeInteger(event.seq) || event.seq <= 0)
60
+ throw new SeekFallback('legacy seek record');
61
+ return { start, end, seq: event.seq };
62
+ };
63
+ const first = await record(0);
64
+ if (first.seq >= before)
65
+ return 0;
66
+ let low = first.end, high = size, lower = first.seq, upper = Infinity;
67
+ while (low < high) {
68
+ const found = await record(Math.floor((low + high) / 2));
69
+ if (found.start < low || found.end > high || found.seq <= lower || found.seq >= upper)
70
+ throw new SeekFallback('non-monotonic seek record');
71
+ if (found.seq < before) {
72
+ low = found.end;
73
+ lower = found.seq;
74
+ }
75
+ else {
76
+ high = found.start;
77
+ upper = found.seq;
78
+ }
79
+ }
80
+ return low;
81
+ }
82
+ /** Newest-first lines; optional finite-before seek is only for a healthy writer. */
83
+ async function readLinesBackward(path, visit, signal, seek) {
12
84
  signal?.throwIfAborted();
13
- const file = await open(path, 'r');
85
+ let file;
86
+ try {
87
+ file = await open(path, 'r');
88
+ }
89
+ catch (error) {
90
+ signal?.throwIfAborted();
91
+ if (seek)
92
+ throw new SeekFallback('segment disappeared');
93
+ throw error;
94
+ }
14
95
  try {
15
- let position = (await file.stat()).size;
16
- let carry = Buffer.alloc(0);
17
- while (position > 0) {
96
+ const initial = await file.stat(), key = stamp(initial);
97
+ let position = seek ? await seekBefore(file, initial.size, seek.before, signal) : initial.size;
98
+ let carry = Buffer.alloc(0), more = true;
99
+ scan: while (position > 0) {
18
100
  signal?.throwIfAborted();
19
101
  const size = Math.min(256 * 1024, position);
20
102
  position -= size;
21
103
  const chunk = Buffer.alloc(size);
22
- await file.read(chunk, 0, size, position);
104
+ const { bytesRead } = await file.read(chunk, 0, size, position);
23
105
  signal?.throwIfAborted();
24
- let buffer = Buffer.concat([chunk, carry]);
106
+ if (seek && bytesRead !== size)
107
+ throw new SeekFallback('short segment read');
108
+ const buffer = Buffer.concat([chunk.subarray(0, bytesRead), carry]);
25
109
  let end = buffer.length;
26
110
  for (let i = buffer.length - 1; i >= 0; i--) {
27
111
  if (buffer[i] !== 10)
28
112
  continue;
29
- if (end > i + 1 && !visit(buffer.toString('utf8', i + 1, end)))
30
- return false;
113
+ if (end > i + 1 && !visit(buffer.toString('utf8', i + 1, end))) {
114
+ more = false;
115
+ break scan;
116
+ }
31
117
  end = i;
32
118
  }
33
119
  carry = buffer.subarray(0, end);
34
120
  }
35
- if (carry.length && !visit(carry.toString('utf8')))
36
- return false;
37
- return true;
121
+ if (more && carry.length && !visit(carry.toString('utf8')))
122
+ more = false;
123
+ if (seek) {
124
+ signal?.throwIfAborted();
125
+ const current = await stat(path).catch(() => undefined);
126
+ if (stamp(await file.stat()) !== key || !current || stamp(current) !== key)
127
+ throw new SeekFallback('segment changed during read');
128
+ seek.checked.push({ path, key });
129
+ }
130
+ return more;
131
+ }
132
+ catch (error) {
133
+ signal?.throwIfAborted();
134
+ if (seek && !(error instanceof SeekFallback))
135
+ throw new SeekFallback('seek read unavailable');
136
+ throw error;
38
137
  }
39
138
  finally {
40
139
  await file.close();
41
140
  }
42
141
  }
142
+ /** Existing writer provenance, never a substitute for the captured barrier. */
143
+ async function seekManifest(dir, files, signal) {
144
+ try {
145
+ signal?.throwIfAborted();
146
+ const raw = await readFile(join(dir, 'manifest.json'), { encoding: 'utf8', signal }), value = JSON.parse(raw);
147
+ if (value.schemaVersion !== 1 || !Number.isSafeInteger(value.nextSeq) || value.nextSeq < 1
148
+ || !Array.isArray(value.segments) || value.segments.length !== files.length
149
+ || value.segments.some((name, i) => name !== files[files.length - 1 - i])
150
+ || (value.failedGenerations !== undefined && (!Array.isArray(value.failedGenerations) || value.failedGenerations.length > 0)))
151
+ return;
152
+ return raw;
153
+ }
154
+ catch {
155
+ signal?.throwIfAborted();
156
+ return;
157
+ }
158
+ }
43
159
  // Rotated segments never change, so their last cursor is reused until the file itself changes.
44
160
  const lastSeqs = new Map();
45
161
  /** The last event cursor in a segment, read from its tail; undefined when none is readable. */
@@ -72,6 +188,17 @@ async function lastSeq(path) {
72
188
  * `nextCursor` is the newest ledger position seen, for forward `after` paging.
73
189
  */
74
190
  export async function conversationTailPage(stateDir, request, snapshot, resumedSessionId, signal) {
191
+ try {
192
+ return await tailPage(stateDir, request, snapshot, resumedSessionId, signal, true);
193
+ }
194
+ catch (error) {
195
+ signal?.throwIfAborted();
196
+ if (!(error instanceof SeekFallback))
197
+ throw error;
198
+ return tailPage(stateDir, request, snapshot, resumedSessionId, signal, false);
199
+ }
200
+ }
201
+ async function tailPage(stateDir, request, snapshot, resumedSessionId, signal, allowSeek) {
75
202
  signal?.throwIfAborted();
76
203
  const before = request.before === undefined ? Infinity : Number(request.before);
77
204
  if (before !== Infinity && (!Number.isSafeInteger(before) || before < 0))
@@ -93,6 +220,11 @@ export async function conversationTailPage(stateDir, request, snapshot, resumedS
93
220
  // generation on every open. Legacy/incomplete snapshots still need context.
94
221
  const complete = !snapshot.observationFailure && Number.isSafeInteger(barrier) && barrier >= 0
95
222
  && Number.isSafeInteger(observation.stateRevision) && observation.stateRevision >= 0;
223
+ const dir = join(stateDir, '.conversation');
224
+ const manifest = allowSeek && before !== Infinity && complete && !snapshot.historyDegraded
225
+ ? await seekManifest(dir, files, signal) : undefined;
226
+ const seek = manifest === undefined ? undefined : { before, checked: [] };
227
+ let previousSeq = Infinity;
96
228
  const needsCapabilities = !complete || !Array.isArray(observation.commands);
97
229
  const pending = new Set(complete && Array.isArray(observation.pendingPermissions)
98
230
  && snapshot.pendingPermissionIds.every(id => observation.pendingPermissions.some(permission => permission.permissionId === id))
@@ -117,9 +249,16 @@ export async function conversationTailPage(stateDir, request, snapshot, resumedS
117
249
  event = JSON.parse(line);
118
250
  }
119
251
  catch {
252
+ if (seek)
253
+ throw new SeekFallback('unreadable page record');
120
254
  return true;
121
255
  }
122
256
  const seq = Number(event.seq);
257
+ if (seek) {
258
+ if (event.schemaVersion !== 1 || !Number.isSafeInteger(seq) || seq <= 0 || seq >= previousSeq)
259
+ throw new SeekFallback('non-monotonic page record');
260
+ previousSeq = seq;
261
+ }
123
262
  if (!Number.isSafeInteger(seq))
124
263
  return true;
125
264
  if (barrier !== undefined && seq > barrier)
@@ -142,10 +281,22 @@ export async function conversationTailPage(stateDir, request, snapshot, resumedS
142
281
  hasOlder = true;
143
282
  }
144
283
  return !hasOlder || wantsContext();
145
- }, signal);
284
+ }, signal, seek);
146
285
  if (!more)
147
286
  break;
148
287
  }
288
+ if (seek) {
289
+ signal?.throwIfAborted();
290
+ const currentFiles = (await readdir(dir).catch(() => { throw new SeekFallback('ledger disappeared'); })).filter(n => /^events-\d{6}\.jsonl$/.test(n)).sort().reverse();
291
+ if (await seekManifest(dir, currentFiles, signal) !== manifest || currentFiles.join() !== files.join())
292
+ throw new SeekFallback('segments rotated during page');
293
+ for (const checked of seek.checked) {
294
+ signal?.throwIfAborted();
295
+ const current = await stat(checked.path).catch(() => undefined);
296
+ if (!current || stamp(current) !== checked.key)
297
+ throw new SeekFallback('segment replaced during page');
298
+ }
299
+ }
149
300
  events.reverse();
150
301
  context.reverse();
151
302
  return { events, snapshot, hasOlder, context, hasMore: false,
@@ -328,8 +328,8 @@ export declare class TaskRoomApplicationService {
328
328
  taskId: string;
329
329
  }): Promise<TaskProvisioningContinuationResult>;
330
330
  /**
331
- * Before the Owner can join, register the room as this task's with the notification service, so own-room message
332
- * preferences apply to its first message. An unreachable service stops here and the saga retries from this step.
331
+ * Attempt registration before the Owner joins. A notification failure must not become a room saga error:
332
+ * admission continues and unassociated room messages may arrive as ordinary notifications.
333
333
  */
334
334
  private associateRoomNotifications;
335
335
  private setOwnerRoomCommands;
@@ -707,8 +707,8 @@ export class TaskRoomApplicationService {
707
707
  }
708
708
  }
709
709
  /**
710
- * Before the Owner can join, register the room as this task's with the notification service, so own-room message
711
- * preferences apply to its first message. An unreachable service stops here and the saga retries from this step.
710
+ * Attempt registration before the Owner joins. A notification failure must not become a room saga error:
711
+ * admission continues and unassociated room messages may arrive as ordinary notifications.
712
712
  */
713
713
  async associateRoomNotifications(room) {
714
714
  if (!room.task_id || !room.room_identity_cid)
@@ -716,9 +716,8 @@ export class TaskRoomApplicationService {
716
716
  try {
717
717
  await (this.deps.associateRoomNotifications ?? associateRoomNotifications)(room.task_id, room.room_identity_cid);
718
718
  }
719
- catch (error) {
720
- setSagaError(room.room_id, error instanceof Error ? error.message : String(error), `Start or repair the notification service, then run ours-fleet task start ${room.task_id}.`, 'waiting_cowork');
721
- throw error;
719
+ catch {
720
+ console.warn('Mission room notification registration failed; continuing room admission with ordinary notifications');
722
721
  }
723
722
  }
724
723
  async setOwnerRoomCommands(cowork, roomId, role) {
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "2.2.2",
3
- "buildId": "b0c5de2e0887",
4
- "commit": "ebe447505d63119b299e3619d12a6399f1e2e49f",
2
+ "version": "2.2.4",
3
+ "buildId": "009d1f631eac",
4
+ "commit": "ed07f55a29a98edfbcadccb5ebc07fccaa24adae",
5
5
  "dirty": false,
6
- "builtAt": "2026-10-29T07:51:29.222Z",
6
+ "builtAt": "2026-10-29T09:19:37.374Z",
7
7
  "capabilities": [
8
8
  "service.refresh-v1",
9
9
  "managed-cli.codex-reasoning-effort-v1",
@@ -10,10 +10,9 @@ export declare function missionRequest(config: ProducerConfig, path: string, bod
10
10
  data: any;
11
11
  }>;
12
12
  /**
13
- * Register a room as this task's before the Owner can join it: with own-room messages off, the service must know
14
- * the room before the room can message the Owner. Unreachable service or unconfirmed coverage → throws, so room
15
- * activation waits and retries. No producer configured → nothing to gate. An older service without the route →
16
- * admitted with a warning (it cannot suppress own-room messages either).
13
+ * Attempt to register the task room before the Owner joins, so preferences apply from its first message when confirmed.
14
+ * Unreachable service or unconfirmed coverage throws to the caller, which continues admission with ordinary notifications.
15
+ * No producer configured → nothing to register. An older service without the route returns with a warning.
17
16
  */
18
17
  export declare function associateRoomNotifications(taskId: string, roomCid: string, options?: {
19
18
  config?: ProducerConfig | undefined;
@@ -47,10 +47,9 @@ async function registerMission(config, taskId, manifest, request) {
47
47
  throw new MissionOwnershipUnavailable(`notification service refused room registration (HTTP ${status})`);
48
48
  }
49
49
  /**
50
- * Register a room as this task's before the Owner can join it: with own-room messages off, the service must know
51
- * the room before the room can message the Owner. Unreachable service or unconfirmed coverage → throws, so room
52
- * activation waits and retries. No producer configured → nothing to gate. An older service without the route →
53
- * admitted with a warning (it cannot suppress own-room messages either).
50
+ * Attempt to register the task room before the Owner joins, so preferences apply from its first message when confirmed.
51
+ * Unreachable service or unconfirmed coverage throws to the caller, which continues admission with ordinary notifications.
52
+ * No producer configured → nothing to register. An older service without the route returns with a warning.
54
53
  */
55
54
  export async function associateRoomNotifications(taskId, roomCid, options = {}) {
56
55
  const manifest = recordNotificationRooms(taskId, [roomCid]);
@@ -66,7 +65,7 @@ export async function associateRoomNotifications(taskId, roomCid, options = {})
66
65
  return;
67
66
  }
68
67
  if (error instanceof MissionOwnershipUnavailable)
69
- throw new Error('Waiting for notification service to register the mission room');
68
+ throw new Error('Notification service did not register the mission room');
70
69
  throw error;
71
70
  }
72
71
  }
@@ -15,8 +15,7 @@ export declare class TaskNotificationProducer {
15
15
  private offset;
16
16
  private stopped;
17
17
  private readonly shutdown;
18
- private bootstrapped;
19
- private bootstrapAt;
18
+ private bootstrapAttempted;
20
19
  constructor(config: ProducerConfig, warn?: (line: string) => void, request?: typeof fetch,
21
20
  /** Existing tasks and their layout room CIDs, registered once per start before own-room OFF can be chosen. */
22
21
  missions?: {
@@ -16,8 +16,7 @@ export class TaskNotificationProducer {
16
16
  offset = 0;
17
17
  stopped = false;
18
18
  shutdown = new AbortController();
19
- bootstrapped = false;
20
- bootstrapAt = 0;
19
+ bootstrapAttempted = false;
21
20
  constructor(config, warn = () => { }, request = fetch,
22
21
  /** Existing tasks and their layout room CIDs, registered once per start before own-room OFF can be chosen. */
23
22
  missions) {
@@ -68,21 +67,14 @@ export class TaskNotificationProducer {
68
67
  // Deletion first: a retired task's notifications go before any other delivery work in this pass.
69
68
  if (convertRetiredTaskNotifications() || await drainMissionCleanup(this.config, this.warn, this.request))
70
69
  this.retry();
71
- if (this.missions && !this.bootstrapped && !this.stopped) {
72
- // A failed run waits 30s: unavailable inventory or a refused registration needs repair, not a tight loop.
73
- const wait = this.bootstrapAt - Date.now();
74
- if (wait > 0)
75
- this.retry(wait);
76
- else {
77
- try {
78
- await bootstrapMissionRooms(this.config, this.missions.tasks, this.missions.layoutRooms, this.request);
79
- this.bootstrapped = true;
80
- }
81
- catch {
82
- this.warn('Mission room registration pending; own-room message preference stays unavailable');
83
- this.bootstrapAt = Date.now() + 30_000;
84
- this.retry(30_000);
85
- }
70
+ if (this.missions && !this.bootstrapAttempted && !this.stopped) {
71
+ // One startup attempt. Association failure neither blocks task events nor schedules another registration.
72
+ this.bootstrapAttempted = true;
73
+ try {
74
+ await bootstrapMissionRooms(this.config, this.missions.tasks, this.missions.layoutRooms, this.request);
75
+ }
76
+ catch {
77
+ this.warn('Mission room registration failed; own-room message preference may be unavailable');
86
78
  }
87
79
  }
88
80
  const pending = pendingTaskNotifications(this.warn);
@@ -30,7 +30,7 @@ export declare class RoomLayoutService {
30
30
  supervisor(id?: string, templates?: Record<string, AgentTemplateDefinition>): NativeLayoutSupervisor;
31
31
  private owner;
32
32
  private engine;
33
- /** A task's layout rooms are registered with the notification service before the Owner can join them. */
33
+ /** A task's layout rooms attempt notification registration before Owner admission. */
34
34
  private beforeOwner;
35
35
  open(id: string, cleanup?: boolean): RoomLayout;
36
36
  /** `scope` lets an owner (e.g. a Task) snapshot an adjusted definition and pin agent working directories. */
@@ -180,7 +180,7 @@ export class RoomLayoutService {
180
180
  assertLayoutFile(path);
181
181
  return new RoomLayout(path, () => createCoworkAdapter(), this.supervisor(id), cleanup ? undefined : this.owner(), this.beforeOwner(id));
182
182
  }
183
- /** A task's layout rooms are registered with the notification service before the Owner can join them. */
183
+ /** A task's layout rooms attempt notification registration before Owner admission. */
184
184
  beforeOwner(id) {
185
185
  const taskId = /^task-([0-9a-z]{9}[0-9a-f]{8})$/.exec(id)?.[1];
186
186
  return taskId ? async (roomCid) => (await import('../notifications/mission-ownership.js')).associateRoomNotifications(taskId, roomCid) : undefined;
@@ -104,12 +104,12 @@ export declare class RoomLayout {
104
104
  private file;
105
105
  private supervisor;
106
106
  private owner?;
107
- /** Runs before the Owner may join each room; a failure stops here and the next pass retries it. */
107
+ /** Best-effort notification registration before the Owner joins each room. */
108
108
  private beforeOwner?;
109
109
  private coworkAdapter?;
110
110
  private readonly coworkFactory;
111
111
  constructor(file: string, cowork: CoworkAdapter | (() => CoworkAdapter), supervisor: LayoutSupervisor, owner?: LayoutOwner | undefined,
112
- /** Runs before the Owner may join each room; a failure stops here and the next pass retries it. */
112
+ /** Best-effort notification registration before the Owner joins each room. */
113
113
  beforeOwner?: ((roomCid: string) => Promise<void>) | undefined);
114
114
  private get cowork();
115
115
  snapshot(): RoomLayoutState;
@@ -43,7 +43,7 @@ export class RoomLayout {
43
43
  coworkAdapter;
44
44
  coworkFactory;
45
45
  constructor(file, cowork, supervisor, owner,
46
- /** Runs before the Owner may join each room; a failure stops here and the next pass retries it. */
46
+ /** Best-effort notification registration before the Owner joins each room. */
47
47
  beforeOwner) {
48
48
  this.file = file;
49
49
  this.supervisor = supervisor;
@@ -165,8 +165,14 @@ export class RoomLayout {
165
165
  activation_requirements: !spec.members.length && s.owner ? [{ role: s.owner.role, count: 1 }]
166
166
  : [...new Set(spec.members.map(roleFor))].map(role => ({ role, count: spec.members.filter(m => roleFor(m) === role).length })),
167
167
  }), native => { room.native = native; s.order.push(key); });
168
- if (s.owner && !room.owner_ready && this.beforeOwner)
169
- await this.beforeOwner(room.native.identity_cid);
168
+ if (s.owner && !room.owner_ready && this.beforeOwner) {
169
+ try {
170
+ await this.beforeOwner(room.native.identity_cid);
171
+ }
172
+ catch {
173
+ console.warn('Mission room notification registration failed; continuing room admission with ordinary notifications');
174
+ }
175
+ }
170
176
  if (s.owner && !room.owner_ready)
171
177
  await this.mutation(s, `owner:${key}`, async () => {
172
178
  await this.cowork.acceptInvite(room.native.room_id, this.owner.invite, { role: s.owner.role, expected_cid: s.owner.cid });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "2.2.2",
3
+ "version": "2.2.4",
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",