@ours.network/fleet 2.1.2 → 2.2.0

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 (35) hide show
  1. package/dist/application/conversation-history.d.ts +8 -0
  2. package/dist/application/conversation-history.js +37 -2
  3. package/dist/application/task-room-service.d.ts +7 -0
  4. package/dist/application/task-room-service.js +19 -0
  5. package/dist/build-info.json +4 -4
  6. package/dist/notifications/fleet-producer.d.ts +0 -1
  7. package/dist/notifications/fleet-producer.js +65 -47
  8. package/dist/notifications/mission-ownership.d.ts +31 -0
  9. package/dist/notifications/mission-ownership.js +148 -0
  10. package/dist/notifications/outbox.d.ts +10 -0
  11. package/dist/notifications/outbox.js +28 -2
  12. package/dist/notifications/task-producer.d.ts +11 -1
  13. package/dist/notifications/task-producer.js +34 -5
  14. package/dist/rooms-tasks/layout-service.d.ts +2 -0
  15. package/dist/rooms-tasks/layout-service.js +7 -2
  16. package/dist/rooms-tasks/layout.d.ts +5 -1
  17. package/dist/rooms-tasks/layout.js +7 -1
  18. package/dist/rooms-tasks/notification-manifest.d.ts +29 -0
  19. package/dist/rooms-tasks/notification-manifest.js +65 -0
  20. package/dist/rooms-tasks/task-review.d.ts +2 -0
  21. package/dist/rooms-tasks/task-review.js +7 -5
  22. package/dist/rooms-tasks/task-state.d.ts +35 -0
  23. package/dist/rooms-tasks/task-state.js +151 -17
  24. package/dist/rooms-tasks/types.d.ts +10 -0
  25. package/dist/session/acp.d.ts +3 -0
  26. package/dist/session/acp.js +58 -1
  27. package/dist/session/codex-app-server.js +22 -1
  28. package/dist/session/conversation-types.d.ts +8 -2
  29. package/dist/session/session-ownership.d.ts +18 -0
  30. package/dist/session/session-ownership.js +74 -0
  31. package/dist/supervisor/manager.js +9 -2
  32. package/dist/web/resource-views.d.ts +2 -0
  33. package/dist/web/resource-views.js +4 -2
  34. package/dist/web/server.js +29 -8
  35. package/package.json +5 -5
@@ -115,6 +115,13 @@ export interface TaskNotificationEvent {
115
115
  taskId: string;
116
116
  };
117
117
  taskId: string;
118
+ /** Born read in the notification service: pushed, never an unread item. Absent on events queued before it. */
119
+ delivery?: 'push-only';
120
+ }
121
+ /** Persisted reads of Progress entries and To review revisions; absent until the first post-upgrade change. */
122
+ export interface TaskContributionReads {
123
+ progress_read: string[];
124
+ review_revision: number;
118
125
  }
119
126
  /** Who wrote a progress entry or review change, as established by the Fleet surface. */
120
127
  export type TaskContributionAuthor = {
@@ -162,6 +169,9 @@ export interface TaskRecord {
162
169
  review?: TaskReview;
163
170
  /** Pending lifecycle events, committed atomically with their task change. */
164
171
  notification_events?: TaskNotificationEvent[];
172
+ contribution_reads?: TaskContributionReads;
173
+ /** Rooms whose notifications belong to this task; only grows. */
174
+ notification_manifest?: import('./notification-manifest.js').TaskNotificationManifest;
165
175
  workspace?: import('./workspace.js').OwnedWorkspace;
166
176
  task_id: string;
167
177
  /** Stable organizational list identifier. Missing legacy values mean `default`. */
@@ -208,6 +208,9 @@ export declare class AcpSession implements AgentSession {
208
208
  private activeTurn?;
209
209
  private constructor();
210
210
  static start(options: AcpSessionOptions): Promise<AcpSession>;
211
+ /** Construction failed after the child and connection exist: close the connection, stop the child (bounded TERM, then
212
+ * KILL), and check liveness at the end. Every cleanup failure is kept; a child not proven exited is named by pid. */
213
+ private static releaseFailedStart;
211
214
  /**
212
215
  * Honest restart recovery: a prompt that was admitted but never
213
216
  * started is safe to dispatch again; a turn that had already started may
@@ -1,4 +1,5 @@
1
1
  import { ConversationObservation } from './conversation-observation.js';
2
+ import { recordSessionOwnership } from './session-ownership.js';
2
3
  import { appendTaskCreated } from './task-notice.js';
3
4
  import { spawn } from 'node:child_process';
4
5
  import { createHash, randomUUID } from 'node:crypto';
@@ -336,7 +337,17 @@ export class AcpSession {
336
337
  this.conversation = new ConversationEventStore(join(options.stateDir, '.conversation'), {
337
338
  roleId: options.name, log: line => options.log(`[${options.name}] ${line}`),
338
339
  });
340
+ // Only earlier generations are below the page floor. The current ownership
341
+ // binding must be visible through both canonical pages and passive replay.
339
342
  this.conversationStartCursor = this.conversation.lastCursor();
343
+ // Unavailable ownership refuses the start; release the ledger descriptor this constructor opened.
344
+ try {
345
+ recordSessionOwnership(this.conversation, this.sessionGeneration, options.stateDir);
346
+ }
347
+ catch (error) {
348
+ this.conversation.close();
349
+ throw error;
350
+ }
340
351
  // A durable append can precede its inventory mutation. Capture only after
341
352
  // the synchronous owner checkpoint, never the state at append notification.
342
353
  this.conversation.subscribe(() => this.observation.scheduleCapture());
@@ -417,7 +428,14 @@ export class AcpSession {
417
428
  });
418
429
  const stream = acp.ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout));
419
430
  const connection = app.connect(stream);
420
- instance = new AcpSession(options, child, connection);
431
+ try {
432
+ instance = new AcpSession(options, child, connection);
433
+ }
434
+ catch (error) {
435
+ // A cleanup exception never replaces the ownership failure: both are kept.
436
+ throw await AcpSession.releaseFailedStart(child, connection, error)
437
+ .catch(cleanupError => new AggregateError([error, cleanupError], 'ACP start failed and its cleanup failed', { cause: error }));
438
+ }
421
439
  try {
422
440
  await instance.initialize();
423
441
  instance.startStallWatchdog();
@@ -430,6 +448,45 @@ export class AcpSession {
430
448
  throw error;
431
449
  }
432
450
  }
451
+ /** Construction failed after the child and connection exist: close the connection, stop the child (bounded TERM, then
452
+ * KILL), and check liveness at the end. Every cleanup failure is kept; a child not proven exited is named by pid. */
453
+ static async releaseFailedStart(child, connection, error) {
454
+ const failures = [];
455
+ try {
456
+ connection.close();
457
+ }
458
+ catch (closeError) {
459
+ failures.push(closeError);
460
+ }
461
+ const exited = () => child.exitCode !== null || child.signalCode !== null;
462
+ const wait = (ms) => new Promise(resolve => {
463
+ if (exited())
464
+ return resolve();
465
+ const settle = () => { clearTimeout(timer); child.off('exit', settle); resolve(); };
466
+ const timer = setTimeout(settle, ms);
467
+ timer.unref();
468
+ child.on('exit', settle);
469
+ });
470
+ let killFailed = false;
471
+ const signal = (name) => { try {
472
+ child.kill(name);
473
+ }
474
+ catch (killError) {
475
+ killFailed = true;
476
+ failures.push(killError);
477
+ } };
478
+ if (!exited()) {
479
+ signal('SIGTERM');
480
+ await wait(2_500);
481
+ }
482
+ if (!exited()) {
483
+ signal('SIGKILL');
484
+ await wait(2_500);
485
+ }
486
+ if (!exited())
487
+ failures.push(new Error(`ACP child (pid ${child.pid}) ${killFailed ? 'exit unproven: a stop signal failed' : 'is still running'}`));
488
+ return failures.length ? new AggregateError([error, ...failures], 'ACP start failed and its release is unproven', { cause: error }) : error;
489
+ }
433
490
  /**
434
491
  * Honest restart recovery: a prompt that was admitted but never
435
492
  * started is safe to dispatch again; a turn that had already started may
@@ -1,4 +1,5 @@
1
1
  import { ConversationObservation } from './conversation-observation.js';
2
+ import { recordSessionOwnership } from './session-ownership.js';
2
3
  import { appendTaskCreated } from './task-notice.js';
3
4
  import { createHash, randomUUID } from 'node:crypto';
4
5
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
@@ -173,7 +174,17 @@ export class CodexAppServerSession {
173
174
  this.conversation = new ConversationEventStore(join(options.stateDir, '.conversation'), {
174
175
  roleId: options.name, log: line => options.log(`[${options.name}] ${line}`),
175
176
  });
177
+ // Only earlier generations are below the page floor. The current ownership
178
+ // binding must be visible through both canonical pages and passive replay.
176
179
  this.conversationStartCursor = this.conversation.lastCursor();
180
+ // Unavailable ownership refuses the start; release the ledger descriptor this constructor opened.
181
+ try {
182
+ recordSessionOwnership(this.conversation, this.sessionGeneration, options.stateDir);
183
+ }
184
+ catch (error) {
185
+ this.conversation.close();
186
+ throw error;
187
+ }
177
188
  // A durable append can precede its inventory mutation. Capture only after
178
189
  // the synchronous owner checkpoint, never the state at append notification.
179
190
  this.conversation.subscribe(() => this.observation.scheduleCapture());
@@ -191,7 +202,17 @@ export class CodexAppServerSession {
191
202
  onRequest: (method, id, params) => instance?.serverRequest(method, id, params),
192
203
  onExit: exit => instance?.transportExited(exit),
193
204
  });
194
- instance = new CodexAppServerSession(options, transport);
205
+ try {
206
+ instance = new CodexAppServerSession(options, transport);
207
+ }
208
+ catch (error) {
209
+ // Existing bounded TERM→KILL; its waits are not proof, so liveness is checked after it returns.
210
+ const failures = [];
211
+ await transport.close().catch((closeError) => { failures.push(closeError); });
212
+ if (transport.isAlive())
213
+ failures.push(new Error(`Codex app-server transport (pid ${transport.pid}) is still running`));
214
+ throw failures.length ? new AggregateError([error, ...failures], 'Codex app-server start failed and its release is unproven', { cause: error }) : error;
215
+ }
195
216
  try {
196
217
  await instance.initialize();
197
218
  instance.recoverOpenPrompts();
@@ -8,7 +8,7 @@ import type { PromptOrigin, SessionSnapshot, TurnCancellationSource, TurnOutcome
8
8
  * file touches the wire: transport updates are reduced into these shapes by a
9
9
  * session adapter, and the store assigns `seq`/`eventId`/timestamps.
10
10
  */
11
- export type ConversationEventKind = 'prompt.admitted' | 'prompt.started' | 'prompt.interrupt_requested' | 'message.chunk' | 'message.replace' | 'thought.chunk' | 'thought.replace' | 'plan.replace' | 'tool.upsert' | 'tool.content_chunk' | 'permission.requested' | 'permission.resolved' | 'file.attached' | 'fleet.task_created' | 'monitor.delivery' | 'usage.updated' | 'turn.state' | 'turn.completed' | 'session.state' | 'session.info' | 'capabilities.updated' | 'error'
11
+ export type ConversationEventKind = 'prompt.admitted' | 'prompt.started' | 'prompt.interrupt_requested' | 'message.chunk' | 'message.replace' | 'thought.chunk' | 'thought.replace' | 'plan.replace' | 'tool.upsert' | 'tool.content_chunk' | 'permission.requested' | 'permission.resolved' | 'file.attached' | 'fleet.task_created' | 'monitor.delivery' | 'usage.updated' | 'turn.state' | 'turn.completed' | 'session.state' | 'session.info' | 'session.ownership' | 'capabilities.updated' | 'error'
12
12
  /** A well-formed transport update this version cannot represent. Bounded, never a crash. */
13
13
  | 'unsupported';
14
14
  /** Where a conversation record came from. Typed provenance, never prompt text. */
@@ -142,6 +142,12 @@ export interface UsageUpdatedPayload {
142
142
  export interface SessionStatePayload {
143
143
  currentModeId?: string;
144
144
  }
145
+ /** First event of a generation: the launch it belongs to and that launch's task, or null for a non-task role. */
146
+ export interface SessionOwnershipPayload {
147
+ /** Null for a role without temp-supervisor metadata (explicitly standalone). */
148
+ launchId: string | null;
149
+ taskId: string | null;
150
+ }
145
151
  export interface SessionInfoPayload {
146
152
  title?: string | null;
147
153
  updatedAt?: string | null;
@@ -236,7 +242,7 @@ export interface BoundedJson {
236
242
  }
237
243
  export type ConversationPayload = {
238
244
  attachment: import('../file-delivery/types.js').DeliveredFile;
239
- } | MessageChunkPayload | ThoughtChunkPayload | PlanReplacePayload | ToolUpsertPayload | UsageUpdatedPayload | SessionStatePayload | SessionInfoPayload | CapabilitiesUpdatedPayload | UnsupportedPayload | PromptAdmittedPayload | PromptStartedPayload | PromptInterruptRequestedPayload | PermissionRequestedPayload | PermissionResolvedPayload | TurnStatePayload | TurnCompletedPayload | MonitorDeliveryPayload | SessionLifecyclePayload | ErrorPayload | TaskCreatedNotice;
245
+ } | MessageChunkPayload | ThoughtChunkPayload | PlanReplacePayload | ToolUpsertPayload | UsageUpdatedPayload | SessionStatePayload | SessionInfoPayload | SessionOwnershipPayload | CapabilitiesUpdatedPayload | UnsupportedPayload | PromptAdmittedPayload | PromptStartedPayload | PromptInterruptRequestedPayload | PermissionRequestedPayload | PermissionResolvedPayload | TurnStatePayload | TurnCompletedPayload | MonitorDeliveryPayload | SessionLifecyclePayload | ErrorPayload | TaskCreatedNotice;
240
246
  export interface ConversationEventV1 {
241
247
  schemaVersion: 1;
242
248
  roleId: string;
@@ -0,0 +1,18 @@
1
+ import type { ConversationEventStore } from './conversation-store.js';
2
+ /** Durable generation → launch/task evidence, independent of the transcript tail. Bounded to recent generations. */
3
+ export declare const SESSION_OWNERSHIP_FILE = ".session-ownership.json";
4
+ export interface SessionOwnership {
5
+ launchId: string | null;
6
+ taskId: string | null;
7
+ }
8
+ /** Canonical Fleet task identity; a task owner always has a launch. */
9
+ export declare const validOwnerTask: (taskId: unknown) => taskId is string;
10
+ /** Absent evidence is `{}` (no file: ENOENT only). Unreadable or invalid evidence throws and is never treated as absent. */
11
+ export declare function readSessionOwnership(stateDir: string): Record<string, SessionOwnership>;
12
+ /**
13
+ * Bind this session generation to its launch before any prompt can be admitted. A task member's generation names
14
+ * its task; a role without temp-supervisor metadata is explicitly standalone. Existing but unreadable metadata is
15
+ * unavailable evidence: the session refuses to start rather than guess. For a task-owned generation both the
16
+ * sidecar and the ledger event must persist, or the session start fails (no prompt is admitted without its binding).
17
+ */
18
+ export declare function recordSessionOwnership(conversation: ConversationEventStore, sessionGeneration: string, stateDir: string): void;
@@ -0,0 +1,74 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { replaceFileAtomically } from '../atomic-file.js';
4
+ import { readTempSupervisor, TEMP_SUPERVISOR_FILE } from '../temp-lifecycle.js';
5
+ /** Durable generation → launch/task evidence, independent of the transcript tail. Bounded to recent generations. */
6
+ export const SESSION_OWNERSHIP_FILE = '.session-ownership.json';
7
+ const MAX_GENERATIONS = 16;
8
+ /** Canonical Fleet task identity; a task owner always has a launch. */
9
+ export const validOwnerTask = (taskId) => typeof taskId === 'string' && /^[0-9a-z]{9}[0-9a-f]{8}$/.test(taskId);
10
+ /** Absent evidence is `{}` (no file: ENOENT only). Unreadable or invalid evidence throws and is never treated as absent. */
11
+ export function readSessionOwnership(stateDir) {
12
+ let text;
13
+ try {
14
+ text = readFileSync(join(stateDir, SESSION_OWNERSHIP_FILE), 'utf8');
15
+ }
16
+ catch (error) {
17
+ if (error.code === 'ENOENT')
18
+ return {};
19
+ throw error;
20
+ }
21
+ if (text.length > 64 * 1024)
22
+ throw new Error('session ownership evidence too large');
23
+ const value = JSON.parse(text);
24
+ if (value?.schema !== 1 || !value.generations || typeof value.generations !== 'object' || Array.isArray(value.generations))
25
+ throw new Error('invalid session ownership evidence');
26
+ const result = {};
27
+ for (const [generation, owner] of Object.entries(value.generations)) {
28
+ if (!owner || !(owner.launchId === null || typeof owner.launchId === 'string')
29
+ || !(owner.taskId === null || validOwnerTask(owner.taskId)) || (owner.taskId !== null && owner.launchId === null))
30
+ throw new Error('invalid session ownership evidence');
31
+ result[generation] = { launchId: owner.launchId, taskId: owner.taskId };
32
+ }
33
+ return result;
34
+ }
35
+ /**
36
+ * Bind this session generation to its launch before any prompt can be admitted. A task member's generation names
37
+ * its task; a role without temp-supervisor metadata is explicitly standalone. Existing but unreadable metadata is
38
+ * unavailable evidence: the session refuses to start rather than guess. For a task-owned generation both the
39
+ * sidecar and the ledger event must persist, or the session start fails (no prompt is admitted without its binding).
40
+ */
41
+ export function recordSessionOwnership(conversation, sessionGeneration, stateDir) {
42
+ let owner;
43
+ if (!existsSync(join(stateDir, TEMP_SUPERVISOR_FILE)))
44
+ owner = { launchId: null, taskId: null };
45
+ else {
46
+ const metadata = readTempSupervisor(stateDir);
47
+ if (!metadata)
48
+ throw new Error('session ownership unavailable: temp supervisor metadata is unreadable');
49
+ const taskId = metadata.taskOwner?.taskId ?? null;
50
+ if (taskId !== null && !validOwnerTask(taskId))
51
+ throw new Error('session ownership unavailable: invalid task owner');
52
+ owner = { launchId: metadata.launchId, taskId };
53
+ }
54
+ const draft = { kind: 'session.ownership', sessionGeneration, payload: owner };
55
+ if (!owner.taskId) {
56
+ // A standalone role keeps the existing degrade-not-die behaviour. A sidecar that cannot be updated is left exactly as
57
+ // it was (never reset): the ledger event still binds this generation, and the observer refuses to guess from it.
58
+ try {
59
+ writeSidecar(stateDir, sessionGeneration, owner);
60
+ }
61
+ catch { /* evidence preserved; the ledger binding remains */ }
62
+ conversation.appendSafe(draft);
63
+ return;
64
+ }
65
+ writeSidecar(stateDir, sessionGeneration, owner);
66
+ conversation.append(draft);
67
+ }
68
+ /** Initializes only on proven absence. Corrupt or unreadable prior evidence throws and its bytes stay untouched. */
69
+ function writeSidecar(stateDir, sessionGeneration, owner) {
70
+ const generations = readSessionOwnership(stateDir);
71
+ delete generations[sessionGeneration];
72
+ const kept = Object.entries(generations).slice(-(MAX_GENERATIONS - 1));
73
+ replaceFileAtomically(join(stateDir, SESSION_OWNERSHIP_FILE), JSON.stringify({ schema: 1, generations: Object.fromEntries([...kept, [sessionGeneration, owner]]) }) + '\n', 0o600);
74
+ }
@@ -160,8 +160,15 @@ export async function runFleetManager(entrypoint, deps = {}) {
160
160
  try {
161
161
  try {
162
162
  const config = producerConfig(process.env, undefined, log);
163
- if (config)
164
- notifications = new TaskNotificationProducer(config, log);
163
+ if (config) {
164
+ const [{ listTasks }, { TaskLayouts }] = await Promise.all([import('../rooms-tasks/task-state.js'), import('../application/task-layouts.js')]);
165
+ const layouts = new TaskLayouts();
166
+ notifications = new TaskNotificationProducer(config, log, fetch, {
167
+ // Unreadable task or layout evidence is not an empty inventory: it throws and the bootstrap run stays incomplete.
168
+ tasks: () => listTasks({ strict: true }),
169
+ layoutRooms: task => (layouts.view(task)?.rooms ?? []).flatMap(room => room.identity_cid ? [room.identity_cid] : []),
170
+ });
171
+ }
165
172
  }
166
173
  catch {
167
174
  log('[fleet] task notifications unavailable; committed events retained');
@@ -91,6 +91,8 @@ export interface ResourceDescriptor {
91
91
  list?: string;
92
92
  groupByList?: boolean;
93
93
  includeDeleting?: boolean;
94
+ /** Previews: each agent's newest assistant message time only, without its text. */
95
+ activityOnly?: boolean;
94
96
  }
95
97
  export declare function canonicalResourceDescriptor(input: unknown): ResourceDescriptor;
96
98
  interface BoundProjection {
@@ -156,9 +156,11 @@ export function canonicalResourceDescriptor(input) {
156
156
  throw Error('invalid resource descriptor');
157
157
  const value = input;
158
158
  if (!/^[A-Za-z0-9_-]{1,128}$/.test(value.viewId) || !['directory', 'roles', 'tasks', 'task-lists', 'configuration', 'previews', 'task-layout', 'creation-action', 'action', 'member-addition', 'login', 'onboarding-health', 'subscriptions'].includes(value.resource)
159
- || Object.keys(value).some(key => !['viewId', 'resource', 'includeTemporary', 'includeDefinitions', 'ids', 'state', 'list', 'groupByList', 'includeDeleting', 'entityId', 'receiptId', 'provider'].includes(key)))
159
+ || Object.keys(value).some(key => !['viewId', 'resource', 'includeTemporary', 'includeDefinitions', 'ids', 'state', 'list', 'groupByList', 'includeDeleting', 'entityId', 'receiptId', 'provider', 'activityOnly'].includes(key)))
160
160
  throw Error('invalid resource descriptor');
161
- for (const key of ['includeTemporary', 'includeDefinitions', 'groupByList', 'includeDeleting'])
161
+ if (value.activityOnly !== undefined && value.resource !== 'previews')
162
+ throw Error('invalid resource descriptor');
163
+ for (const key of ['includeTemporary', 'includeDefinitions', 'groupByList', 'includeDeleting', 'activityOnly'])
162
164
  if (value[key] !== undefined && typeof value[key] !== 'boolean')
163
165
  throw Error('invalid resource descriptor');
164
166
  if (value.ids !== undefined && (!Array.isArray(value.ids) || value.ids.length > 500 || value.ids.some(id => typeof id !== 'string' || !/^[A-Za-z0-9_-]{1,120}$/.test(id))))
@@ -2,7 +2,7 @@ import { FleetResourceViews, ResourceBindingError, canonicalResourceDescriptor }
2
2
  import { publishFleetResourceCommit } from '../application/resource-observer.js';
3
3
  import { stateRoot, defaultConfigPath } from '../paths.js';
4
4
  import { EMPTY_TASK_LAYOUT } from '../rooms-tasks/empty-layout.js';
5
- import { conversationPreview } from '../application/conversation-history.js';
5
+ import { conversationActivity, conversationPreview } from '../application/conversation-history.js';
6
6
  import { registerConversationStream } from './conversation-stream.js';
7
7
  import { conversationHasAttachment } from '../application/conversation-history.js';
8
8
  import { readDeliveredFile } from '../file-delivery/store.js';
@@ -26,7 +26,7 @@ import { WebAuth } from './auth.js';
26
26
  import { FleetEventBus } from './events.js';
27
27
  import { ROLE_NAME_RE } from '../config.js';
28
28
  import { TaskListError } from '../rooms-tasks/task-lists.js';
29
- import { TaskStateError } from '../rooms-tasks/task-state.js';
29
+ import { acknowledgeTaskProgress, acknowledgeTaskReview, contributionUnread, TaskStateError } from '../rooms-tasks/task-state.js';
30
30
  import { appendTaskProgress, getTaskReview, listTaskProgress, publicProgressEntry, publicReview, readTaskReviewArtifact, setTaskReviewMarkdown, taskContributionSummary, TaskReviewError, } from '../rooms-tasks/task-review.js';
31
31
  import { isProvider } from '../subscriptions/store.js';
32
32
  const ONBOARDING_WORK = ['coordination', 'development', 'review'];
@@ -371,9 +371,12 @@ export async function buildWebServer(services, boundary, options = {}) {
371
371
  const filter = { strict, ...(state ? { state } : {}), ...(query.list ? { list: query.list } : {}),
372
372
  ...(query.includeDeleting === 'true' ? { includeDeleting: true } : {}) };
373
373
  const api = requireTaskRooms();
374
+ // Navigation totals cover every task in scope, not only the filtered or visible cards.
375
+ const unreadTotal = () => api.listTasks({ strict, ...(query.includeDeleting === 'true' ? { includeDeleting: true } : {}) })
376
+ .reduce((sum, task) => { const unread = contributionUnread(task); return sum + unread.progress + (unread.review ? 1 : 0); }, 0);
374
377
  return taskApi(() => query.groupByList === 'true'
375
- ? { groups: api.groupedTasks(filter).map(group => ({ ...group, tasks: group.tasks.map(task => wireTask(api.withLayoutRooms(task))) })) }
376
- : { tasks: api.listTasks(filter).map(task => wireTask(api.withLayoutRooms(task))) });
378
+ ? { groups: api.groupedTasks(filter).map(group => ({ ...group, tasks: group.tasks.map(task => wireTask(api.withLayoutRooms(task))) })), contribution_unread_total: unreadTotal() }
379
+ : { tasks: api.listTasks(filter).map(task => wireTask(api.withLayoutRooms(task))), contribution_unread_total: unreadTotal() });
377
380
  };
378
381
  const project = async (descriptor, signal, owner = '') => {
379
382
  signal.throwIfAborted();
@@ -426,7 +429,8 @@ export async function buildWebServer(services, boundary, options = {}) {
426
429
  return { ...services.configuration.read(descriptor.includeDefinitions ?? false), provenance: services.presetProvenance?.read() ?? null };
427
430
  case 'directory': {
428
431
  const [roles, tasks] = await Promise.all([services.query.list(true, true), projectTasks({})]);
429
- return { roles, tasks: tasks.tasks, lists: requireTaskRooms().listTaskLists() };
432
+ const { tasks: rows, contribution_unread_total } = tasks;
433
+ return { roles, tasks: rows, contribution_unread_total, lists: requireTaskRooms().listTaskLists() };
430
434
  }
431
435
  case 'previews': {
432
436
  const roles = await services.repository.list(true), selected = new Map(roles.map(role => [role.id, role]));
@@ -437,14 +441,15 @@ export async function buildWebServer(services, boundary, options = {}) {
437
441
  if (!role)
438
442
  continue;
439
443
  try {
440
- previews[id] = dir ? await conversationPreview(dir, signal) : { text: 'No recent agent output', at: '' };
444
+ previews[id] = descriptor.activityOnly ? (dir ? await conversationActivity(dir, signal) : { at: '' })
445
+ : dir ? await conversationPreview(dir, signal) : { text: 'No recent agent output', at: '' };
441
446
  }
442
447
  catch {
443
448
  signal.throwIfAborted();
444
449
  // A damaged/unreadable ledger is unavailable, not empty or removed.
445
450
  // Keep this selected row observable so a repair can refresh it,
446
451
  // without retiring previews for the other selected roles.
447
- previews[id] = { text: 'Preview unavailable', at: '', unavailable: true };
452
+ previews[id] = descriptor.activityOnly ? { at: '', unavailable: true } : { text: 'Preview unavailable', at: '', unavailable: true };
448
453
  }
449
454
  }
450
455
  return { previews };
@@ -505,7 +510,7 @@ export async function buildWebServer(services, boundary, options = {}) {
505
510
  for (const resource of ['task-layout', 'creation-action', 'action', 'member-addition', 'login', 'onboarding-health'])
506
511
  app.get('/api/v1/' + resource, async () => { throw new ResourceBindingError(409, 'resource binding required'); });
507
512
  app.get('/api/v1/directory', async (request) => { auth.authenticate(request); return project({ viewId: 'read', resource: 'directory' }, new AbortController().signal); });
508
- app.get('/api/v1/previews', async (request) => { auth.authenticate(request); const ids = String(request.query.ids ?? '').split(',').filter(Boolean); return project(canonicalResourceDescriptor({ viewId: 'read', resource: 'previews', ids }), new AbortController().signal); });
513
+ app.get('/api/v1/previews', async (request) => { auth.authenticate(request); const query = request.query, ids = String(query.ids ?? '').split(',').filter(Boolean); return project(canonicalResourceDescriptor({ viewId: 'read', resource: 'previews', ids, ...(query.activityOnly === 'true' ? { activityOnly: true } : {}) }), new AbortController().signal); });
509
514
  app.get('/api/v1/task-lists', async (request) => {
510
515
  auth.authenticate(request);
511
516
  return project({ viewId: 'read', resource: 'task-lists' }, new AbortController().signal);
@@ -619,6 +624,22 @@ export async function buildWebServer(services, boundary, options = {}) {
619
624
  reply.code(201);
620
625
  return { entry: publicProgressEntry(result.entry), replayed: result.replayed };
621
626
  });
627
+ /** Acknowledge Progress entries the browser actually showed; returns the task's remaining unread. */
628
+ app.post('/api/v1/tasks/:id/progress/read', async (request) => {
629
+ auth.authenticate(request, true);
630
+ const ids = request.body?.ids;
631
+ if (!Array.isArray(ids) || ids.length > 200 || ids.some(id => typeof id !== 'string' || id.length > 64))
632
+ throw new FleetError('invalid_request', 'ids must be at most 200 progress entry IDs');
633
+ return { unread: await reviewApi(() => acknowledgeTaskProgress(request.params.id, ids)) };
634
+ });
635
+ /** Acknowledge the To review revision the browser fetched and rendered. */
636
+ app.post('/api/v1/tasks/:id/review/read', async (request) => {
637
+ auth.authenticate(request, true);
638
+ const revision = request.body?.revision;
639
+ if (!Number.isSafeInteger(revision) || revision < 0)
640
+ throw new FleetError('invalid_request', 'revision must be a non-negative integer');
641
+ return { unread: await reviewApi(() => acknowledgeTaskReview(request.params.id, revision)) };
642
+ });
622
643
  app.get('/api/v1/tasks/:id/review', async (request) => {
623
644
  auth.authenticate(request);
624
645
  const review = await reviewApi(() => getTaskReview(request.params.id));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "2.1.2",
3
+ "version": "2.2.0",
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",
@@ -49,9 +49,9 @@
49
49
  "dependencies": {
50
50
  "@agentclientprotocol/sdk": "^1.3.0",
51
51
  "@fastify/websocket": "^11.2.0",
52
- "@ours.network/cli": "4.0.0",
53
- "@ours.network/mcp": "2.0.0",
54
- "@ours.network/sdk": "5.0.0",
52
+ "@ours.network/cli": "4.0.1",
53
+ "@ours.network/mcp": "2.0.1",
54
+ "@ours.network/sdk": "5.0.1",
55
55
  "commander": "^12.1.0",
56
56
  "fastify": "^5.4.0",
57
57
  "qrcode": "1.5.4",
@@ -65,7 +65,7 @@
65
65
  "@agentclientprotocol/codex-acp": "1.10.0"
66
66
  },
67
67
  "devDependencies": {
68
- "@ours.network/daemon": "5.0.0",
68
+ "@ours.network/daemon": "5.0.1",
69
69
  "@types/node": "^22.20.4",
70
70
  "@types/qrcode": "^1.5.6",
71
71
  "@types/ws": "^8.18.1",