@ours.network/fleet 0.11.1 → 0.13.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 (43) hide show
  1. package/README.md +225 -0
  2. package/dist/briefing.js +25 -0
  3. package/dist/cli.js +408 -1
  4. package/dist/config.d.ts +31 -1
  5. package/dist/config.js +123 -2
  6. package/dist/docs.d.ts +1 -1
  7. package/dist/docs.js +143 -0
  8. package/dist/duration.js +7 -3
  9. package/dist/index.d.ts +2 -1
  10. package/dist/index.js +1 -0
  11. package/dist/loops/config.d.ts +30 -0
  12. package/dist/loops/config.js +135 -0
  13. package/dist/loops/manager.d.ts +48 -0
  14. package/dist/loops/manager.js +237 -0
  15. package/dist/loops/state.d.ts +54 -0
  16. package/dist/loops/state.js +148 -0
  17. package/dist/monitor.js +26 -2
  18. package/dist/owner-channel/attachments.d.ts +74 -0
  19. package/dist/owner-channel/attachments.js +378 -0
  20. package/dist/owner-channel/channel.d.ts +167 -0
  21. package/dist/owner-channel/channel.js +874 -0
  22. package/dist/owner-channel/mcp.d.ts +24 -0
  23. package/dist/owner-channel/mcp.js +123 -0
  24. package/dist/owner-channel/notices.d.ts +21 -0
  25. package/dist/owner-channel/notices.js +66 -0
  26. package/dist/owner-channel/state.d.ts +44 -0
  27. package/dist/owner-channel/state.js +184 -0
  28. package/dist/owner-channel/tasks.d.ts +62 -0
  29. package/dist/owner-channel/tasks.js +246 -0
  30. package/dist/resolved-plan.js +12 -0
  31. package/dist/runner.d.ts +3 -0
  32. package/dist/runner.js +112 -5
  33. package/dist/session/acp.d.ts +4 -2
  34. package/dist/session/acp.js +82 -25
  35. package/dist/session/arbiter.d.ts +42 -0
  36. package/dist/session/arbiter.js +72 -0
  37. package/dist/session/control.d.ts +12 -1
  38. package/dist/session/control.js +56 -3
  39. package/dist/session/types.d.ts +28 -2
  40. package/dist/session/types.js +5 -2
  41. package/dist/spawn.js +7 -3
  42. package/dist/supervisor/systemd.js +12 -2
  43. package/package.json +1 -1
@@ -40,6 +40,7 @@ export class AcpSession {
40
40
  steeringSupported = false;
41
41
  capabilities;
42
42
  controllerCount = 0;
43
+ activeTurn;
43
44
  constructor(options, child, connection) {
44
45
  this.options = options;
45
46
  this.child = child;
@@ -117,24 +118,24 @@ export class AcpSession {
117
118
  if (!this.sessionId || !this.isAlive())
118
119
  throw new SessionControlError('offline', this.lastError ?? 'ACP session is offline');
119
120
  if (options.interrupt)
120
- await this.interrupt();
121
+ await this.cancelActive(options.interruptSource ?? 'local-console');
121
122
  // Interrupting delivery must still use steering when supported. With no
122
123
  // live turn, the extension starts one and acknowledges `startedNewTurn`
123
124
  // immediately; a normal session/prompt would keep the monitor blocked until
124
125
  // the entire wake-triggered turn terminated.
125
126
  if (options.steer && this.steeringSupported) {
126
127
  const promptId = randomUUID();
127
- return { promptId, queuedBehind: 0, completion: this.steerPrompt(text) };
128
+ return { promptId, queuedBehind: 0, completion: this.steerPrompt(text), origin: options.origin };
128
129
  }
129
130
  const promptId = randomUUID();
130
131
  const queuedBehind = this.queueDepth++;
131
- const run = this.promptTail.then(() => this.runPrompt(text, promptId));
132
+ const run = this.promptTail.then(() => this.runPrompt(text, promptId, options.origin));
132
133
  this.promptTail = run.then(() => undefined, () => undefined);
133
134
  const completion = run.then(result => { this.queueDepth = Math.max(0, this.queueDepth - 1); return result; }, error => {
134
135
  this.queueDepth = Math.max(0, this.queueDepth - 1);
135
136
  return turnResult(false, 'failed', error?.message ?? String(error));
136
137
  });
137
- return { promptId, queuedBehind, completion };
138
+ return { promptId, queuedBehind, completion, origin: options.origin };
138
139
  }
139
140
  async submitPrompt(text, options = {}) {
140
141
  try {
@@ -146,10 +147,24 @@ export class AcpSession {
146
147
  throw error;
147
148
  }
148
149
  }
149
- async interrupt() {
150
+ async interrupt(source = 'local-console') {
151
+ await this.cancelActive(source);
152
+ }
153
+ async cancelActive(source) {
150
154
  if (!this.sessionId)
151
155
  return;
152
- await this.connection.agent.notify(acp.methods.agent.session.cancel, { sessionId: this.sessionId });
156
+ const active = this.activeTurn;
157
+ const previousSource = active?.cancellationSource;
158
+ if (active && (source === 'owner' || source === 'local-console' || !previousSource))
159
+ active.cancellationSource = source;
160
+ try {
161
+ await this.connection.agent.notify(acp.methods.agent.session.cancel, { sessionId: this.sessionId });
162
+ }
163
+ catch (error) {
164
+ if (this.activeTurn === active && active?.cancellationSource === source)
165
+ active.cancellationSource = previousSource;
166
+ throw error;
167
+ }
153
168
  for (const pending of this.pendingPermissions.values())
154
169
  pending.resolve({ outcome: { outcome: 'cancelled' } });
155
170
  this.pendingPermissions.clear();
@@ -162,6 +177,8 @@ export class AcpSession {
162
177
  this.pendingPermissions.delete(permissionId);
163
178
  pending.resolve({ outcome: { outcome: 'selected', optionId } });
164
179
  this.events.emit('permission', {
180
+ turnId: this.activeTurn?.id,
181
+ origin: this.activeTurn?.origin,
165
182
  permissionId,
166
183
  status: 'completed',
167
184
  decision: chosen.kind.startsWith('reject') ? 'denied' : 'allowed',
@@ -237,30 +254,43 @@ export class AcpSession {
237
254
  this.readiness = 'idle';
238
255
  this.events.emit('state', { status: 'idle', text: `ACP session ${this.sessionId}` });
239
256
  }
240
- async runPrompt(text, turnId = randomUUID()) {
257
+ async runPrompt(text, turnId = randomUUID(), origin) {
241
258
  if (!this.sessionId || !this.isAlive())
242
259
  return turnResult(false, 'failed', this.lastError ?? 'ACP session is offline');
243
260
  this.readiness = 'running';
244
- this.events.emit('state', { turnId, status: 'running' });
261
+ this.activeTurn = { id: turnId, output: '', origin };
262
+ this.events.emit('state', { turnId, status: 'running', origin });
245
263
  try {
246
264
  const response = await this.connection.agent.request(acp.methods.agent.session.prompt, {
247
265
  sessionId: this.sessionId,
248
266
  prompt: [{ type: 'text', text }],
249
267
  });
250
268
  this.readiness = 'idle';
251
- this.events.emit('turn_stop', { turnId, stopReason: response.stopReason });
269
+ this.events.emit('turn_stop', {
270
+ turnId, stopReason: response.stopReason, origin,
271
+ cancellationSource: this.activeTurn?.id === turnId
272
+ ? this.activeTurn.cancellationSource : undefined,
273
+ });
252
274
  this.events.emit('state', { status: 'idle' });
253
275
  // The prompt was accepted either way — the agent answered. Whether the
254
276
  // turn SUCCEEDED is a separate question, and only `stopReason` answers it.
255
- return turnResult(true, classifyStopReason(response.stopReason), response.stopReason);
277
+ return turnResult(true, classifyStopReason(response.stopReason), response.stopReason, this.activeTurn?.id === turnId ? this.activeTurn.output : undefined, this.activeTurn?.id === turnId ? this.activeTurn.cancellationSource : undefined);
256
278
  }
257
279
  catch (error) {
258
- this.lastError = error?.message ?? String(error);
280
+ const detail = error?.message ?? String(error);
281
+ this.lastError = origin?.kind === 'scheduled-loop' ? 'scheduled-loop turn failed' : detail;
259
282
  this.readiness = this.isAlive() ? 'idle' : 'failed';
260
- this.events.emit('error', { turnId, text: this.lastError });
283
+ this.events.emit('error', {
284
+ turnId, origin,
285
+ text: origin?.kind === 'scheduled-loop' ? 'scheduled-loop turn failed' : this.lastError,
286
+ });
261
287
  if (this.isAlive())
262
288
  this.events.emit('state', { status: 'idle' });
263
- return turnResult(false, 'failed', this.lastError);
289
+ return turnResult(false, 'failed', this.lastError, this.activeTurn?.id === turnId ? this.activeTurn.output : undefined);
290
+ }
291
+ finally {
292
+ if (this.activeTurn?.id === turnId)
293
+ this.activeTurn = undefined;
264
294
  }
265
295
  }
266
296
  async steerPrompt(text) {
@@ -312,12 +342,18 @@ export class AcpSession {
312
342
  const permissionId = randomUUID();
313
343
  this.readiness = 'awaiting_permission';
314
344
  this.events.emit('permission', {
345
+ turnId: this.activeTurn?.id,
346
+ origin: this.activeTurn?.origin,
315
347
  permissionId,
316
- toolCallId: params.toolCall.toolCallId,
317
- title: params.toolCall.title ?? 'Permission requested',
348
+ toolCallId: this.activeTurn?.origin?.kind === 'scheduled-loop'
349
+ ? 'scheduled-loop-tool' : params.toolCall.toolCallId,
350
+ title: this.activeTurn?.origin?.kind === 'scheduled-loop'
351
+ ? 'Scheduled-loop permission requested' : params.toolCall.title ?? 'Permission requested',
318
352
  status: 'pending',
319
353
  options: params.options.map(option => ({
320
- optionId: option.optionId, name: option.name, kind: option.kind,
354
+ optionId: option.optionId,
355
+ name: this.activeTurn?.origin?.kind === 'scheduled-loop' ? option.kind : option.name,
356
+ kind: option.kind,
321
357
  })),
322
358
  });
323
359
  return new Promise(resolve => {
@@ -332,16 +368,24 @@ export class AcpSession {
332
368
  settleAutomatically(params, option, decision, policy, reason) {
333
369
  const settled = option ? decision : 'cancelled';
334
370
  this.events.emit('permission', {
371
+ turnId: this.activeTurn?.id,
372
+ origin: this.activeTurn?.origin,
335
373
  permissionId: randomUUID(),
336
- toolCallId: params.toolCall.toolCallId,
337
- title: params.toolCall.title ?? 'Permission requested',
374
+ toolCallId: this.activeTurn?.origin?.kind === 'scheduled-loop'
375
+ ? 'scheduled-loop-tool' : params.toolCall.toolCallId,
338
376
  status: 'completed',
339
377
  decision: settled,
340
378
  decisionSource: 'automatic',
341
379
  policy,
342
380
  reason: option ? reason : `${reason}, but the agent offered no matching option`,
343
381
  optionId: option?.optionId,
344
- options: params.options.map(o => ({ optionId: o.optionId, name: o.name, kind: o.kind })),
382
+ title: this.activeTurn?.origin?.kind === 'scheduled-loop'
383
+ ? 'Scheduled-loop permission requested' : params.toolCall.title ?? 'Permission requested',
384
+ options: params.options.map(o => ({
385
+ optionId: o.optionId,
386
+ name: this.activeTurn?.origin?.kind === 'scheduled-loop' ? o.kind : o.name,
387
+ kind: o.kind,
388
+ })),
345
389
  });
346
390
  return option
347
391
  ? { outcome: { outcome: 'selected', optionId: option.optionId } }
@@ -364,28 +408,41 @@ export class AcpSession {
364
408
  });
365
409
  }
366
410
  recordUpdate(update) {
411
+ const scheduled = this.activeTurn?.origin?.kind === 'scheduled-loop';
367
412
  switch (update.sessionUpdate) {
368
413
  case 'agent_message_chunk':
414
+ if (this.activeTurn && update.content.type === 'text')
415
+ this.activeTurn.output += update.content.text;
369
416
  this.events.emit('agent_text', {
370
- text: update.content.type === 'text' ? update.content.text : `[${update.content.type}]`,
417
+ turnId: this.activeTurn?.id,
418
+ origin: this.activeTurn?.origin,
419
+ text: scheduled ? '[scheduled-loop output redacted]'
420
+ : update.content.type === 'text' ? update.content.text : `[${update.content.type}]`,
371
421
  });
372
422
  break;
373
423
  case 'agent_thought_chunk':
374
424
  this.events.emit('thought', {
375
- text: update.content.type === 'text' ? update.content.text : `[${update.content.type}]`,
425
+ turnId: this.activeTurn?.id,
426
+ origin: this.activeTurn?.origin,
427
+ text: scheduled ? '[scheduled-loop thought redacted]'
428
+ : update.content.type === 'text' ? update.content.text : `[${update.content.type}]`,
376
429
  });
377
430
  break;
378
431
  case 'tool_call':
379
432
  this.events.emit('tool_call', {
380
- toolCallId: update.toolCallId,
381
- title: update.title,
433
+ turnId: this.activeTurn?.id,
434
+ origin: this.activeTurn?.origin,
435
+ toolCallId: scheduled ? 'scheduled-loop-tool' : update.toolCallId,
436
+ title: scheduled ? 'scheduled-loop tool' : update.title,
382
437
  status: update.status,
383
438
  });
384
439
  break;
385
440
  case 'tool_call_update':
386
441
  this.events.emit('tool_update', {
387
- toolCallId: update.toolCallId,
388
- title: update.title ?? undefined,
442
+ turnId: this.activeTurn?.id,
443
+ origin: this.activeTurn?.origin,
444
+ toolCallId: scheduled ? 'scheduled-loop-tool' : update.toolCallId,
445
+ title: scheduled ? 'scheduled-loop tool' : update.title ?? undefined,
389
446
  status: update.status ?? undefined,
390
447
  });
391
448
  break;
@@ -0,0 +1,42 @@
1
+ import type { ExitRecord, PromptOrigin, QueuedPrompt, SessionEvent, SessionHandle, SessionSnapshot, SubmitPromptOptions, TurnCancellationSource, TurnResult } from './types.js';
2
+ export type ScheduledAttempt = {
3
+ state: 'started';
4
+ queued: QueuedPrompt;
5
+ } | {
6
+ state: 'skipped_busy';
7
+ } | {
8
+ state: 'unavailable';
9
+ error: string;
10
+ };
11
+ /**
12
+ * One in-process admission boundary for every producer targeting a role.
13
+ * Scheduled callers get an atomic idle recheck plus submission; ordinary
14
+ * producers retain ACP queue semantics while making their unsettled claim
15
+ * visible before another producer can inspect idle state.
16
+ */
17
+ export declare class RoleTurnArbiter implements SessionHandle {
18
+ private readonly session;
19
+ readonly backend: import("../config.js").SessionBackendId;
20
+ readonly pid: number;
21
+ private tail;
22
+ private unsettled;
23
+ private stopping;
24
+ constructor(session: SessionHandle);
25
+ private exclusive;
26
+ private track;
27
+ queuePrompt(text: string, options?: SubmitPromptOptions): Promise<QueuedPrompt>;
28
+ submitPrompt(text: string, options?: SubmitPromptOptions): Promise<TurnResult>;
29
+ tryScheduled(text: string, origin: Extract<PromptOrigin, {
30
+ kind: 'scheduled-loop';
31
+ }>, beforeQueue?: () => void | Promise<void>): Promise<ScheduledAttempt>;
32
+ stopScheduledAdmission(): void;
33
+ isAlive(): boolean;
34
+ snapshot(): SessionSnapshot;
35
+ interrupt(source?: TurnCancellationSource): Promise<void>;
36
+ respondPermission(permissionId: string, optionId: string): boolean;
37
+ eventsSince(seq: number): SessionEvent[];
38
+ subscribe(listener: (event: SessionEvent) => void): () => void;
39
+ setControllerAttached(attached: boolean): void;
40
+ exitResult(): ExitRecord | null;
41
+ close(): Promise<void>;
42
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * One in-process admission boundary for every producer targeting a role.
3
+ * Scheduled callers get an atomic idle recheck plus submission; ordinary
4
+ * producers retain ACP queue semantics while making their unsettled claim
5
+ * visible before another producer can inspect idle state.
6
+ */
7
+ export class RoleTurnArbiter {
8
+ session;
9
+ backend;
10
+ pid;
11
+ tail = Promise.resolve();
12
+ unsettled = 0;
13
+ stopping = false;
14
+ constructor(session) {
15
+ this.session = session;
16
+ this.backend = session.backend;
17
+ this.pid = session.pid;
18
+ }
19
+ exclusive(operation) {
20
+ const run = this.tail.then(operation);
21
+ this.tail = run.then(() => undefined, () => undefined);
22
+ return run;
23
+ }
24
+ track(queued) {
25
+ this.unsettled++;
26
+ const completion = queued.completion.finally(() => { this.unsettled = Math.max(0, this.unsettled - 1); });
27
+ return { ...queued, completion };
28
+ }
29
+ queuePrompt(text, options = {}) {
30
+ return this.exclusive(async () => this.track(await this.session.queuePrompt(text, options)));
31
+ }
32
+ async submitPrompt(text, options = {}) {
33
+ return (await this.queuePrompt(text, options)).completion;
34
+ }
35
+ async tryScheduled(text, origin, beforeQueue) {
36
+ // Give owner/console/I/O callbacks already ready in this event-loop turn a
37
+ // chance to claim the arbiter first. Scheduled work is best-effort; humans
38
+ // and authenticated ingress have priority at the idle boundary.
39
+ await new Promise(resolve => setImmediate(resolve));
40
+ return this.exclusive(async () => {
41
+ const snapshot = this.session.snapshot();
42
+ if (this.stopping || !snapshot.alive || snapshot.readiness === 'failed')
43
+ return { state: 'unavailable', error: this.stopping ? 'role is stopping' : 'session is unavailable' };
44
+ if (snapshot.readiness !== 'idle' || this.unsettled > 0)
45
+ return { state: 'skipped_busy' };
46
+ try {
47
+ await beforeQueue?.();
48
+ const queued = await this.session.queuePrompt(text, { interrupt: false, origin });
49
+ if (queued.queuedBehind > 0)
50
+ return { state: 'unavailable', error: 'scheduled admission race' };
51
+ return { state: 'started', queued: this.track(queued) };
52
+ }
53
+ catch (error) {
54
+ return { state: 'unavailable', error: error?.message ?? String(error) };
55
+ }
56
+ });
57
+ }
58
+ stopScheduledAdmission() { this.stopping = true; }
59
+ isAlive() { return this.session.isAlive(); }
60
+ snapshot() { return this.session.snapshot(); }
61
+ interrupt(source = 'local-console') {
62
+ return this.exclusive(() => this.session.interrupt(source));
63
+ }
64
+ respondPermission(permissionId, optionId) {
65
+ return this.session.respondPermission(permissionId, optionId);
66
+ }
67
+ eventsSince(seq) { return this.session.eventsSince(seq); }
68
+ subscribe(listener) { return this.session.subscribe(listener); }
69
+ setControllerAttached(attached) { this.session.setControllerAttached(attached); }
70
+ exitResult() { return this.session.exitResult(); }
71
+ close() { return this.session.close(); }
72
+ }
@@ -1,16 +1,20 @@
1
1
  import { type Socket } from 'node:net';
2
2
  import type { ControlFailureKind, SessionHandle } from './types.js';
3
+ import type { OwnerChannelHandle, OwnerChannelManagementRequest } from '../owner-channel/channel.js';
4
+ import type { ScheduledLoopManagerHandle } from '../loops/manager.js';
3
5
  export interface ControlRequest {
4
6
  version: 1 | 2;
5
7
  id: string;
6
8
  token: string;
7
- command: 'status' | 'snapshot' | 'submit_prompt' | 'respond_permission' | 'interrupt' | 'follow' | 'events_since';
9
+ command: 'status' | 'snapshot' | 'submit_prompt' | 'respond_permission' | 'interrupt' | 'follow' | 'events_since' | 'owner_channel_manage' | 'loop_status' | 'loop_run_now' | 'loop_disable' | 'loop_enable' | 'reload_config';
8
10
  text?: string;
9
11
  permissionId?: string;
10
12
  optionId?: string;
11
13
  since?: number;
12
14
  /** Existing clients omit this and remain interactive controllers. */
13
15
  controller?: boolean;
16
+ ownerChannel?: OwnerChannelManagementRequest;
17
+ loop?: string;
14
18
  }
15
19
  export interface ControlResponse {
16
20
  version: 1;
@@ -71,9 +75,16 @@ export declare class RoleControlServer {
71
75
  private readonly socketPath;
72
76
  private readonly token;
73
77
  private readonly sockets;
78
+ private ownerChannel?;
79
+ private loopManager?;
80
+ private reloadConfig?;
74
81
  constructor(stateDir: string, session: SessionHandle, log: (line: string) => void);
75
82
  start(): Promise<void>;
76
83
  close(): Promise<void>;
84
+ /** Attach only the already-started supervisor-owned channel client. */
85
+ setOwnerChannel(ownerChannel: OwnerChannelHandle | undefined): void;
86
+ setLoopManager(loopManager: ScheduledLoopManagerHandle | undefined): void;
87
+ setConfigReloader(reloadConfig: (() => Promise<unknown>) | undefined): void;
77
88
  private accept;
78
89
  private handle;
79
90
  private write;
@@ -95,6 +95,9 @@ export class RoleControlServer {
95
95
  socketPath;
96
96
  token;
97
97
  sockets = new Set();
98
+ ownerChannel;
99
+ loopManager;
100
+ reloadConfig;
98
101
  constructor(stateDir, session, log) {
99
102
  this.session = session;
100
103
  this.log = log;
@@ -127,6 +130,16 @@ export class RoleControlServer {
127
130
  await new Promise(resolve => this.server.close(() => resolve()));
128
131
  rmSync(this.socketPath, { force: true });
129
132
  }
133
+ /** Attach only the already-started supervisor-owned channel client. */
134
+ setOwnerChannel(ownerChannel) {
135
+ this.ownerChannel = ownerChannel;
136
+ }
137
+ setLoopManager(loopManager) {
138
+ this.loopManager = loopManager;
139
+ }
140
+ setConfigReloader(reloadConfig) {
141
+ this.reloadConfig = reloadConfig;
142
+ }
130
143
  accept(socket) {
131
144
  this.sockets.add(socket);
132
145
  socket.setEncoding('utf8');
@@ -202,7 +215,9 @@ export class RoleControlServer {
202
215
  // Answer on QUEUE ACCEPTANCE, not on turn completion. A turn can run
203
216
  // for minutes; blocking here made every `send` into a busy agent time
204
217
  // out, and the timeout was then reported as a dead agent.
205
- const queued = await this.session.queuePrompt(request.text);
218
+ const queued = await this.session.queuePrompt(request.text, {
219
+ origin: { kind: 'local-console' },
220
+ });
206
221
  this.write(socket, {
207
222
  version: 1, id: request.id, ok: true,
208
223
  result: {
@@ -224,9 +239,47 @@ export class RoleControlServer {
224
239
  return;
225
240
  }
226
241
  case 'interrupt':
227
- await this.session.interrupt();
242
+ await this.session.interrupt('local-console');
228
243
  this.write(socket, { version: 1, id: request.id, ok: true });
229
244
  return;
245
+ case 'loop_status': {
246
+ if (!this.loopManager)
247
+ throw new SessionControlError('rejected', 'scheduled loops are unavailable for this role');
248
+ this.write(socket, { version: 1, id: request.id, ok: true, result: this.loopManager.status() });
249
+ return;
250
+ }
251
+ case 'loop_run_now':
252
+ case 'loop_disable':
253
+ case 'loop_enable': {
254
+ if (request.version !== 2 || !request.loop)
255
+ throw new SessionControlError('rejected', 'version 2 and loop name are required');
256
+ if (!this.loopManager)
257
+ throw new SessionControlError('rejected', 'scheduled loops are unavailable for this role');
258
+ const result = request.command === 'loop_run_now'
259
+ ? await this.loopManager.runNow(request.loop)
260
+ : request.command === 'loop_disable'
261
+ ? this.loopManager.disable(request.loop)
262
+ : this.loopManager.enable(request.loop);
263
+ this.write(socket, { version: 1, id: request.id, ok: true, result });
264
+ return;
265
+ }
266
+ case 'reload_config': {
267
+ if (request.version !== 2 || !this.reloadConfig)
268
+ throw new SessionControlError('rejected', 'config reload is unavailable for this role');
269
+ this.write(socket, {
270
+ version: 1, id: request.id, ok: true, result: await this.reloadConfig(),
271
+ });
272
+ return;
273
+ }
274
+ case 'owner_channel_manage': {
275
+ if (!request.ownerChannel || typeof request.ownerChannel.action !== 'string')
276
+ throw new SessionControlError('rejected', 'owner-channel management action is required');
277
+ if (!this.ownerChannel)
278
+ throw new SessionControlError('rejected', 'owner channel is disabled or unavailable for this role');
279
+ const result = await this.ownerChannel.manage(request.ownerChannel);
280
+ this.write(socket, { version: 1, id: request.id, ok: true, result });
281
+ return;
282
+ }
230
283
  case 'events_since': {
231
284
  const since = Number.isFinite(request.since) ? Number(request.since) : 0;
232
285
  const events = this.session.eventsSince(since);
@@ -323,7 +376,7 @@ export async function controlRequest(stateDir, request, timeoutMs = 120_000) {
323
376
  socket.end();
324
377
  });
325
378
  socket.once('connect', () => socket.write(JSON.stringify({
326
- version: 1, id, token, ...request,
379
+ version: 2, id, token, ...request,
327
380
  }) + '\n'));
328
381
  });
329
382
  }
@@ -1,6 +1,21 @@
1
1
  import type { SessionBackendId } from '../config.js';
2
2
  export type SessionReadiness = 'starting' | 'idle' | 'running' | 'awaiting_permission' | 'failed';
3
3
  export type TurnOutcome = 'completed' | 'refused' | 'cancelled' | 'failed' | 'inconclusive';
4
+ export type TurnCancellationSource = 'owner' | 'local-console' | 'fleet-monitor' | 'scheduled-loop' | 'shutdown';
5
+ export type PromptOrigin = {
6
+ kind: 'startup';
7
+ } | {
8
+ kind: 'local-console';
9
+ } | {
10
+ kind: 'owner';
11
+ requestId: string;
12
+ } | {
13
+ kind: 'fleet-monitor';
14
+ } | {
15
+ kind: 'scheduled-loop';
16
+ loop: string;
17
+ runId: string;
18
+ };
4
19
  /**
5
20
  * Two independent facts about one turn, deliberately kept apart:
6
21
  *
@@ -19,6 +34,10 @@ export interface TurnResult {
19
34
  outcome: TurnOutcome;
20
35
  succeeded: boolean;
21
36
  detail?: string;
37
+ /** Present only when fleet can prove what initiated a cancelled turn. */
38
+ cancellationSource?: TurnCancellationSource;
39
+ /** Final assistant text captured structurally by a backend, when available. */
40
+ output?: string;
22
41
  }
23
42
  /**
24
43
  * Why a control operation failed. The distinctions exist because collapsing
@@ -39,6 +58,7 @@ export interface QueuedPrompt {
39
58
  promptId: string;
40
59
  /** Turns already queued ahead of this one. 0 means it starts immediately. */
41
60
  queuedBehind: number;
61
+ origin?: PromptOrigin;
42
62
  /** The turn's terminal result. Never rejects. */
43
63
  completion: Promise<TurnResult>;
44
64
  }
@@ -75,10 +95,14 @@ export declare function classifyChildExit(code: number | null, signal: string |
75
95
  /** The single definition of terminal success. Nothing else may re-derive it. */
76
96
  export declare const isTerminalSuccess: (outcome: TurnOutcome) => boolean;
77
97
  /** Build a TurnResult with `succeeded` always consistent with `outcome`. */
78
- export declare function turnResult(accepted: boolean, outcome: TurnOutcome, detail?: string): TurnResult;
98
+ export declare function turnResult(accepted: boolean, outcome: TurnOutcome, detail?: string, output?: string, cancellationSource?: TurnCancellationSource): TurnResult;
79
99
  export interface SubmitPromptOptions {
80
100
  /** Cancel active work before delivering this prompt. */
81
101
  interrupt?: boolean;
102
+ /** Internal provenance; ordinary callers must leave this unset. */
103
+ interruptSource?: TurnCancellationSource;
104
+ /** Typed in-process provenance. Text markers never grant this origin. */
105
+ origin?: PromptOrigin;
82
106
  /** Use the ACP steering extension when available; ignored by other backends. */
83
107
  steer?: boolean;
84
108
  }
@@ -105,6 +129,8 @@ export interface SessionEvent {
105
129
  title?: string;
106
130
  status?: string;
107
131
  stopReason?: string;
132
+ origin?: PromptOrigin;
133
+ cancellationSource?: TurnCancellationSource;
108
134
  options?: Array<{
109
135
  optionId: string;
110
136
  name: string;
@@ -133,7 +159,7 @@ export interface SessionHandle {
133
159
  queuePrompt(text: string, options?: SubmitPromptOptions): Promise<QueuedPrompt>;
134
160
  /** Queue a prompt and wait for its terminal result. */
135
161
  submitPrompt(text: string, options?: SubmitPromptOptions): Promise<TurnResult>;
136
- interrupt(): Promise<void>;
162
+ interrupt(source?: TurnCancellationSource): Promise<void>;
137
163
  respondPermission(permissionId: string, optionId: string): boolean;
138
164
  eventsSince(seq: number): SessionEvent[];
139
165
  subscribe(listener: (event: SessionEvent) => void): () => void;
@@ -37,6 +37,9 @@ export function classifyChildExit(code, signal) {
37
37
  /** The single definition of terminal success. Nothing else may re-derive it. */
38
38
  export const isTerminalSuccess = (outcome) => outcome === 'completed';
39
39
  /** Build a TurnResult with `succeeded` always consistent with `outcome`. */
40
- export function turnResult(accepted, outcome, detail) {
41
- return { accepted, outcome, succeeded: isTerminalSuccess(outcome), detail };
40
+ export function turnResult(accepted, outcome, detail, output, cancellationSource) {
41
+ return {
42
+ accepted, outcome, succeeded: isTerminalSuccess(outcome), detail, output,
43
+ ...(outcome === 'cancelled' && cancellationSource ? { cancellationSource } : {}),
44
+ };
42
45
  }
package/dist/spawn.js CHANGED
@@ -4,7 +4,7 @@ import { join } from 'node:path';
4
4
  import { parse, stringify } from 'yaml';
5
5
  import { agentDir, fleetDDir } from './paths.js';
6
6
  import { validateIsolationConfig } from './isolation/policy.js';
7
- import { loadConfig, resolveAuthProxy, resolveModelChain, resolveMonitorConfig, resolvePermissions, resolveRoleModel, resolveWorklogPolicy, validateMonitorConfig, } from './config.js';
7
+ import { loadConfig, resolveAuthProxy, resolveModelChain, resolveMonitorConfig, resolveOwnerChannelConfig, resolvePermissions, resolveRoleModel, resolveWorklogPolicy, validateMonitorConfig, } from './config.js';
8
8
  import { applyRole, up } from './ops.js';
9
9
  import { START_STAGGER_FILE } from './runner.js';
10
10
  import { buildProvenance, daemonIdentityProvisioner, ensureIdentity, provenanceOf, withCreationTransaction, writeProvenance, writeRoleFile, } from './creation.js';
@@ -186,12 +186,13 @@ export function spawnDryRun(o) {
186
186
  const defaultHarness = cfg.defaults.harness ?? 'claude-code';
187
187
  const inheritsModelDefaults = harness === defaultHarness && raw.model !== null;
188
188
  const model = resolveRoleModel(raw.model, raw.harness, cfg.defaults);
189
+ const session = raw.session ?? cfg.defaults.session ?? 'tmux';
189
190
  const resolvedRole = {
190
191
  ...raw,
191
192
  name: o.name,
192
193
  sourceFile: o.temp ? '(temp dry-run)' : join(fleetDDir(), `${o.name}.yaml`),
193
194
  harness,
194
- session: raw.session ?? cfg.defaults.session ?? 'tmux',
195
+ session,
195
196
  session_options: raw.session_options,
196
197
  permissions: resolvePermissions(cfg.defaults.permissions, raw.permissions),
197
198
  permissionsDeclared: raw.permissions !== undefined || cfg.defaults.permissions !== undefined,
@@ -203,6 +204,7 @@ export function spawnDryRun(o) {
203
204
  harness_options: Object.keys(harnessOptions).length ? harnessOptions : undefined,
204
205
  isolation: raw.isolation ?? cfg.defaults.isolation,
205
206
  monitor: resolveMonitorConfig(cfg.defaults.monitor, raw.monitor),
207
+ owner_channel: resolveOwnerChannelConfig(cfg.defaults.owner_channel, raw.owner_channel, session),
206
208
  worklog: resolveWorklogPolicy(cfg.defaults.worklog, raw.worklog),
207
209
  auth_proxy: resolveAuthProxy(cfg.defaults.auth_proxy, raw.auth_proxy),
208
210
  };
@@ -378,11 +380,12 @@ async function spawnTempInner(o, binPath, launch, tx, guarantee, onStage) {
378
380
  const harness = o.harness ?? defaultHarness ?? 'claude-code';
379
381
  const inheritsModelDefaults = harness === (defaultHarness ?? 'claude-code') && o.model !== null;
380
382
  const model = resolveRoleModel(o.model, o.harness, cfg.defaults);
383
+ const session = o.session ?? cfg.defaults.session ?? 'tmux';
381
384
  const role = {
382
385
  ...fromOpts, // includes `isolation` when --isolation-file was given
383
386
  name: o.name,
384
387
  harness,
385
- session: o.session ?? cfg.defaults.session ?? 'tmux',
388
+ session,
386
389
  identity: o.identity ?? o.name,
387
390
  model,
388
391
  model_chain: resolveModelChain(model, fromOpts.model_chain ?? (inheritsModelDefaults
@@ -393,6 +396,7 @@ async function spawnTempInner(o, binPath, launch, tx, guarantee, onStage) {
393
396
  permissionsDeclared: fromOpts.permissions !== undefined || cfg.defaults.permissions !== undefined,
394
397
  // Temp agents inherit the fleet-wide monitor defaults via the snapshot (design §2).
395
398
  monitor: resolveMonitorConfig(cfg.defaults.monitor, fromOpts.monitor),
399
+ owner_channel: resolveOwnerChannelConfig(cfg.defaults.owner_channel, fromOpts.owner_channel, session),
396
400
  worklog: resolveWorklogPolicy(cfg.defaults.worklog, fromOpts.worklog),
397
401
  auth_proxy: resolveAuthProxy(cfg.defaults.auth_proxy, fromOpts.auth_proxy),
398
402
  sourceFile: '(temp)',
@@ -1,5 +1,5 @@
1
1
  import { mkdirSync, writeFileSync } from 'node:fs';
2
- import { join } from 'node:path';
2
+ import { delimiter, dirname, join } from 'node:path';
3
3
  import { userInfo } from 'node:os';
4
4
  import { home } from '../paths.js';
5
5
  import { realExec } from '../exec.js';
@@ -15,6 +15,8 @@ export const busHint = (stderr) => /user scope bus|XDG_RUNTIME_DIR/.test(stderr)
15
15
  `\n (if linger is already on: export XDG_RUNTIME_DIR=/run/user/$(id -u))`
16
16
  : '';
17
17
  export const unitFor = (name) => `ours-fleet-agent@${name}.service`;
18
+ /** Quote a systemd unit argument and escape its specifier marker. */
19
+ const unitArg = (value) => `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/%/g, '%%')}"`;
18
20
  /**
19
21
  * systemd's own ActiveState vocabulary, classified. `activating` covers
20
22
  * `auto-restart` — the unit is mid-restart, not stopped, so its context stands.
@@ -60,6 +62,13 @@ export function makeSystemdBackend(exec = realExec) {
60
62
  async init(binPath) {
61
63
  const msgs = [];
62
64
  const unitDir = join(home(), '.config', 'systemd', 'user');
65
+ // Lingering user units often start before a login shell imports its PATH.
66
+ // Pin the Node runtime and persist the install-time PATH so the runner and
67
+ // children such as `ours-mcp proxy` resolve the same tools after reboot.
68
+ const servicePath = [...new Set([
69
+ dirname(process.execPath),
70
+ ...(process.env.PATH ?? '').split(delimiter),
71
+ ].filter(Boolean))].join(delimiter);
63
72
  mkdirSync(unitDir, { recursive: true });
64
73
  writeFileSync(join(unitDir, UNIT_TEMPLATE), `[Unit]
65
74
  Description=ours-fleet agent %i
@@ -67,7 +76,8 @@ After=default.target
67
76
 
68
77
  [Service]
69
78
  Type=simple
70
- ExecStart=${binPath} _run %i
79
+ Environment="PATH=${servicePath.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/%/g, '%%')}"
80
+ ExecStart=${unitArg(process.execPath)} ${unitArg(binPath)} _run %i
71
81
  # The RUNNER owns the child-session restart loop, with a counted, backed-off
72
82
  # circuit breaker (3.2). systemd must only recover the runner PROCESS crashing —
73
83
  # Restart=always here would resume the uncounted two-second relaunch loop, and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "0.11.1",
3
+ "version": "0.13.0",
4
4
  "description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, tmux or ACP sessions, supervision, and ours.network messaging.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",