pi-fabric 0.8.2 → 0.9.1

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.
@@ -33,6 +33,19 @@ export declare class ActorManager {
33
33
  * actor inherits the Fabric default (subagents.thinking, default "medium").
34
34
  */
35
35
  setThinking(id: string, thinking: string | undefined): Promise<FabricActorInfo>;
36
+ /**
37
+ * Replace an existing actor's host-event subscriptions. Already-queued work
38
+ * for a removed event still runs, but future dispatches respect the new set.
39
+ * Pass an empty array to pause host-event reactivity while keeping the actor
40
+ * alive and reachable by direct messages and mesh topics.
41
+ */
42
+ setEvents(id: string, events: FabricActorHostEvent[]): Promise<FabricActorInfo>;
43
+ /**
44
+ * Clear an actor's recorded inbox/outbox history. The actor keeps running;
45
+ * only its bounded message log is reset — useful to declutter a long mailbox
46
+ * from the dashboard without stopping the actor.
47
+ */
48
+ clearMessages(id: string): Promise<FabricActorInfo>;
36
49
  tell(id: string, message: string, data?: unknown): {
37
50
  queued: true;
38
51
  messageId: string;
@@ -46,6 +59,14 @@ export declare class ActorManager {
46
59
  }): FabricActorLog;
47
60
  dispatchHostEvent(event: FabricActorHostEvent, payload: unknown): number;
48
61
  stop(id: string): Promise<FabricActorInfo>;
62
+ /**
63
+ * Whether the stop-the-world gate is currently armed. haltAll() arms it
64
+ * (ESC stop-the-world) and the "input" host event lifts it when the user
65
+ * resumes with a new message. Read-only view of the private gate so the
66
+ * ESC handler can treat a repeated lone Esc while already halted as a
67
+ * no-op rather than re-arming and re-notifying.
68
+ */
69
+ get halted(): boolean;
49
70
  /**
50
71
  * Interrupt every non-stopped actor: abort its in-flight run (if any) and
51
72
  * reject every queued message so subsequent execution is cancelled. Unlike
@@ -1 +1 @@
1
- {"version":3,"file":"manager.d.ts","sourceRoot":"","sources":["../../src/actors/manager.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,gBAAgB,EAA2B,MAAM,cAAc,CAAC;AAC9E,OAAO,EAAE,SAAS,EAAkB,KAAK,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAChF,OAAO,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAC;AAE1D,OAAO,KAAK,EAEV,0BAA0B,EAE1B,oBAAoB,EACpB,eAAe,EACf,cAAc,EACd,kBAAkB,EAClB,kBAAkB,EAGnB,MAAM,YAAY,CAAC;AAqIpB,qBAAa,YAAY;;IAYrB,QAAQ,CAAC,SAAS,EAAE,MAAM;IAC1B,QAAQ,CAAC,QAAQ,EAAE,YAAY;IAC/B,QAAQ,CAAC,IAAI,EAAE,SAAS;IACxB,QAAQ,CAAC,UAAU,EAAE,gBAAgB;IACrC,QAAQ,CAAC,SAAS,EAAE,eAAe;IACnC,QAAQ,CAAC,SAAS,EAAE,CAAC,OAAO,EAAE,0BAA0B,KAAK,IAAI;gBALxD,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,YAAY,EACtB,IAAI,EAAE,SAAS,EACf,UAAU,EAAE,gBAAgB,EAC5B,SAAS,EAAE,eAAe,EAC1B,SAAS,EAAE,CAAC,OAAO,EAAE,0BAA0B,KAAK,IAAI,EACjE,OAAO,GAAE;QAAE,SAAS,CAAC,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,OAAO,CAAA;KAAO;IAetD,MAAM,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,eAAe,CAAC;IA4DnE,IAAI,IAAI,eAAe,EAAE;IAIzB,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,eAAe;IAInC;;;;;;OAMG;IACG,QAAQ,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC,eAAe,CAAC;IAU/E;;;;;;OAMG;IACG,WAAW,CAAC,EAAE,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC,eAAe,CAAC;IAerF,IAAI,CAAC,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,GAAG;QAAE,MAAM,EAAE,IAAI,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE;IAmBtF,GAAG,CACD,EAAE,EAAE,MAAM,EACV,OAAO,EAAE,MAAM,EACf,IAAI,CAAC,EAAE,OAAO,EACd,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,kBAAkB,CAAC;IA4C9B,QAAQ,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,SAAK,GAAG,kBAAkB,EAAE;IAMtD,OAAO,CACL,EAAE,EAAE,MAAM,EACV,IAAI,GAAE;QAAE,IAAI,CAAC,EAAE,SAAS,GAAG,KAAK,GAAG,KAAK,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAO,GAC9E,cAAc;IAkCjB,iBAAiB,CAAC,KAAK,EAAE,oBAAoB,EAAE,OAAO,EAAE,OAAO,GAAG,MAAM;IAuBlE,IAAI,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,CAAC;IAmBhD;;;;;;;;OAQG;IACH,OAAO,IAAI;QAAE,MAAM,EAAE,MAAM,CAAA;KAAE;IA2BvB,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,OAAO,EAAE,OAAO,CAAA;KAAE,CAAC;IAajD,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAyiB7B"}
1
+ {"version":3,"file":"manager.d.ts","sourceRoot":"","sources":["../../src/actors/manager.ts"],"names":[],"mappings":"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"}
@@ -15,10 +15,6 @@ const HOST_EVENTS = new Set([
15
15
  "session_compact",
16
16
  ]);
17
17
  const MESSAGE_HISTORY_LIMIT = 100;
18
- // After a user-triggered halt (ESC), ignore host-event dispatches for a short
19
- // window so the interrupt's own turn_end / agent_settled events do not
20
- // immediately re-arm the actors that were just stopped.
21
- const HOST_EVENT_HALT_COOLDOWN_MS = 1_000;
22
18
  const atomicWrite = (filePath, value) => {
23
19
  fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
24
20
  const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
@@ -108,7 +104,11 @@ export class ActorManager {
108
104
  #meshOffset;
109
105
  #polling = false;
110
106
  #closing = false;
111
- #haltedAt = 0;
107
+ // Stop-the-world gate armed by haltAll() (ESC): while true, host-event and
108
+ // mesh dispatch are frozen so interrupted actors are not re-armed by the
109
+ // interrupt's own turn_end / agent_settled events. Lifted when the user
110
+ // resumes by sending a new message (the "input" host event).
111
+ #halted = false;
112
112
  constructor(sessionId, identity, mesh, meshConfig, subagents, onDeliver, options = {}) {
113
113
  this.sessionId = sessionId;
114
114
  this.identity = identity;
@@ -174,6 +174,7 @@ export class ActorManager {
174
174
  ...(request.timeoutMs ? { timeoutMs: request.timeoutMs } : {}),
175
175
  sessionFile: path.join(actorDirectory, "session.jsonl"),
176
176
  queue: [],
177
+ draining: false,
177
178
  messages: [],
178
179
  createdAt: Date.now(),
179
180
  updatedAt: Date.now(),
@@ -239,6 +240,38 @@ export class ActorManager {
239
240
  await this.#publishPresence(actor);
240
241
  return this.#publicInfo(actor);
241
242
  }
243
+ /**
244
+ * Replace an existing actor's host-event subscriptions. Already-queued work
245
+ * for a removed event still runs, but future dispatches respect the new set.
246
+ * Pass an empty array to pause host-event reactivity while keeping the actor
247
+ * alive and reachable by direct messages and mesh topics.
248
+ */
249
+ async setEvents(id, events) {
250
+ const actor = this.#requireActor(id);
251
+ const next = [...new Set(events)];
252
+ for (const event of next) {
253
+ if (!HOST_EVENTS.has(event))
254
+ throw new Error(`Unsupported Fabric actor event: ${event}`);
255
+ }
256
+ actor.events = next;
257
+ actor.updatedAt = Date.now();
258
+ this.#saveActors();
259
+ await this.#publishPresence(actor);
260
+ return this.#publicInfo(actor);
261
+ }
262
+ /**
263
+ * Clear an actor's recorded inbox/outbox history. The actor keeps running;
264
+ * only its bounded message log is reset — useful to declutter a long mailbox
265
+ * from the dashboard without stopping the actor.
266
+ */
267
+ async clearMessages(id) {
268
+ const actor = this.#requireActor(id);
269
+ actor.messages = [];
270
+ actor.updatedAt = Date.now();
271
+ this.#saveActors();
272
+ await this.#publishPresence(actor);
273
+ return this.#publicInfo(actor);
274
+ }
242
275
  tell(id, message, data) {
243
276
  this.#validateDirectMessage(message, data);
244
277
  const actor = this.#requireActiveActor(id);
@@ -337,9 +370,12 @@ export class ActorManager {
337
370
  dispatchHostEvent(event, payload) {
338
371
  if (this.#closing || !this.meshConfig.enabled)
339
372
  return 0;
340
- if (this.#haltedAt > 0 && Date.now() - this.#haltedAt < HOST_EVENT_HALT_COOLDOWN_MS) {
373
+ // The user sending a new message ends a stop-the-world halt: lift the gate
374
+ // before dispatching so input-subscribed actors receive this event.
375
+ if (event === "input")
376
+ this.#halted = false;
377
+ if (this.#halted)
341
378
  return 0;
342
- }
343
379
  let delivered = 0;
344
380
  for (const actor of this.#actors.values()) {
345
381
  if (actor.status === "stopped" || !actor.events.includes(event))
@@ -374,6 +410,16 @@ export class ActorManager {
374
410
  .catch(() => undefined);
375
411
  return this.#publicInfo(actor);
376
412
  }
413
+ /**
414
+ * Whether the stop-the-world gate is currently armed. haltAll() arms it
415
+ * (ESC stop-the-world) and the "input" host event lifts it when the user
416
+ * resumes with a new message. Read-only view of the private gate so the
417
+ * ESC handler can treat a repeated lone Esc while already halted as a
418
+ * no-op rather than re-arming and re-notifying.
419
+ */
420
+ get halted() {
421
+ return this.#halted;
422
+ }
377
423
  /**
378
424
  * Interrupt every non-stopped actor: abort its in-flight run (if any) and
379
425
  * reject every queued message so subsequent execution is cancelled. Unlike
@@ -387,7 +433,11 @@ export class ActorManager {
387
433
  if (!this.meshConfig.enabled)
388
434
  return { halted: 0 };
389
435
  let halted = 0;
390
- this.#haltedAt = Date.now();
436
+ // Arm stop-the-world: freeze host-event and mesh dispatch until the user
437
+ // resumes with a new message. Always arm the gate (even with no active
438
+ // work) so an idle-but-subscribed actor is not re-armed by the interrupt's
439
+ // own settle events.
440
+ this.#halted = true;
391
441
  for (const actor of this.#actors.values()) {
392
442
  if (actor.status === "stopped")
393
443
  continue;
@@ -458,6 +508,7 @@ export class ActorManager {
458
508
  if (existing) {
459
509
  existing.payload = payload;
460
510
  existing.createdAt = Date.now();
511
+ this.#ensureDrain(actor);
461
512
  return existing;
462
513
  }
463
514
  }
@@ -486,123 +537,149 @@ export class ActorManager {
486
537
  data: structuredClone(payload),
487
538
  });
488
539
  void this.#publishPresence(actor);
489
- actor.drain ??= this.#drain(actor).finally(() => {
490
- delete actor.drain;
491
- });
540
+ this.#ensureDrain(actor);
492
541
  return item;
493
542
  }
543
+ /**
544
+ * Ensure exactly one drain loop is processing the actor's queue. The loop
545
+ * clears `actor.draining` synchronously when it exits, so a host-event
546
+ * enqueue that lands in the microtask window between the loop exiting and
547
+ * this drain's promise settling still observes `draining === false` and
548
+ * starts a fresh drain — preventing a queued item from being stranded with
549
+ * no drain to process it (the "stuck at queue:1" race).
550
+ */
551
+ #ensureDrain(actor) {
552
+ if (actor.draining || actor.status === "stopped" || this.#closing)
553
+ return;
554
+ actor.draining = true;
555
+ const drain = this.#drain(actor);
556
+ actor.drain = drain;
557
+ const release = () => {
558
+ if (actor.drain === drain)
559
+ delete actor.drain;
560
+ };
561
+ drain.then(release, release);
562
+ }
494
563
  async #drain(actor) {
495
- while (actor.queue.length > 0 && actor.status !== "stopped" && !this.#closing) {
496
- const item = actor.queue.shift();
497
- if (!item)
498
- break;
499
- actor.status = "running";
500
- actor.updatedAt = Date.now();
501
- delete actor.lastError;
502
- const abortController = new AbortController();
503
- actor.abortController = abortController;
504
- await this.#publishPresence(actor);
505
- let runId;
506
- const previousRunId = actor.lastRunId;
507
- let runCompleted = false;
508
- try {
509
- const result = await this.subagents.run(this.#runRequest(actor, item), abortController.signal);
510
- runId = result.id;
511
- actor.lastRunId = result.id;
512
- runCompleted = result.status === "completed";
513
- if (result.status !== "completed") {
514
- if (actor.responseMode === "directive") {
515
- // A failed directive run is non-fatal: stay silent and keep the
516
- // actor ambient instead of erroring out. Record the run error for
517
- // debugging; the failed run itself is retained (see finally) so
518
- // agents.status(actor.lastRunId) can inspect the full output.
519
- const silent = {
520
- id: randomUUID(),
521
- actorId: actor.id,
522
- actorName: actor.name,
523
- direction: "out",
524
- source: item.source,
525
- createdAt: Date.now(),
526
- action: "silent",
527
- data: { runError: result.error || `Actor run ${result.status}`, runId: result.id },
528
- runId: result.id,
529
- usage: result.usage,
530
- };
531
- this.#recordMessage(actor, silent);
532
- item.resolve?.(structuredClone(silent));
533
- continue;
564
+ try {
565
+ while (actor.queue.length > 0 && actor.status !== "stopped" && !this.#closing) {
566
+ const item = actor.queue.shift();
567
+ if (!item)
568
+ break;
569
+ actor.status = "running";
570
+ actor.updatedAt = Date.now();
571
+ delete actor.lastError;
572
+ const abortController = new AbortController();
573
+ actor.abortController = abortController;
574
+ await this.#publishPresence(actor);
575
+ let runId;
576
+ const previousRunId = actor.lastRunId;
577
+ let runCompleted = false;
578
+ try {
579
+ const result = await this.subagents.run(this.#runRequest(actor, item), abortController.signal);
580
+ runId = result.id;
581
+ actor.lastRunId = result.id;
582
+ runCompleted = result.status === "completed";
583
+ if (result.status !== "completed") {
584
+ if (actor.responseMode === "directive") {
585
+ // A failed directive run is non-fatal: stay silent and keep the
586
+ // actor ambient instead of erroring out. Record the run error for
587
+ // debugging; the failed run itself is retained (see finally) so
588
+ // agents.status(actor.lastRunId) can inspect the full output.
589
+ const silent = {
590
+ id: randomUUID(),
591
+ actorId: actor.id,
592
+ actorName: actor.name,
593
+ direction: "out",
594
+ source: item.source,
595
+ createdAt: Date.now(),
596
+ action: "silent",
597
+ data: { runError: result.error || `Actor run ${result.status}`, runId: result.id },
598
+ runId: result.id,
599
+ usage: result.usage,
600
+ };
601
+ this.#recordMessage(actor, silent);
602
+ item.resolve?.(structuredClone(silent));
603
+ continue;
604
+ }
605
+ throw new Error(result.error || `Actor run ${result.status}`);
534
606
  }
535
- throw new Error(result.error || `Actor run ${result.status}`);
536
- }
537
- const message = this.#outgoingMessage(actor, item, result);
538
- this.#recordMessage(actor, message);
539
- await this.mesh
540
- .publish({
541
- topic: "fabric.actor.output",
542
- kind: message.action ?? "message",
543
- from: { id: actor.id, name: actor.name, kind: "actor", sessionId: this.sessionId },
544
- ...(message.text ? { text: message.text } : {}),
545
- ...(message.data !== undefined ? { data: message.data } : {}),
546
- })
547
- .catch(() => undefined);
548
- if ((message.action === "message" || message.action === "stop") &&
549
- message.text &&
550
- actor.delivery !== "mailbox") {
551
- try {
552
- this.onDeliver({
553
- actor: this.#publicInfo(actor),
554
- message: structuredClone(message),
555
- delivery: actor.delivery,
556
- triggerTurn: actor.triggerTurn,
557
- });
607
+ const message = this.#outgoingMessage(actor, item, result);
608
+ this.#recordMessage(actor, message);
609
+ await this.mesh
610
+ .publish({
611
+ topic: "fabric.actor.output",
612
+ kind: message.action ?? "message",
613
+ from: { id: actor.id, name: actor.name, kind: "actor", sessionId: this.sessionId },
614
+ ...(message.text ? { text: message.text } : {}),
615
+ ...(message.data !== undefined ? { data: message.data } : {}),
616
+ })
617
+ .catch(() => undefined);
618
+ if ((message.action === "message" || message.action === "stop") &&
619
+ message.text &&
620
+ actor.delivery !== "mailbox") {
621
+ try {
622
+ this.onDeliver({
623
+ actor: this.#publicInfo(actor),
624
+ message: structuredClone(message),
625
+ delivery: actor.delivery,
626
+ triggerTurn: actor.triggerTurn,
627
+ });
628
+ }
629
+ catch { /* skip non-cloneable or undeliverable message */ }
630
+ }
631
+ item.resolve?.(structuredClone(message));
632
+ if (message.action === "stop") {
633
+ actor.status = "stopped";
634
+ actor.queue.splice(0).forEach((queued) => queued.reject?.(new Error("Actor stopped")));
558
635
  }
559
- catch { /* skip non-cloneable or undeliverable message */ }
560
- }
561
- item.resolve?.(structuredClone(message));
562
- if (message.action === "stop") {
563
- actor.status = "stopped";
564
- actor.queue.splice(0).forEach((queued) => queued.reject?.(new Error("Actor stopped")));
565
- }
566
- }
567
- catch (error) {
568
- const message = error instanceof Error ? error.message : String(error);
569
- actor.lastError = message;
570
- const failed = {
571
- id: randomUUID(),
572
- actorId: actor.id,
573
- actorName: actor.name,
574
- direction: "out",
575
- source: item.source,
576
- createdAt: Date.now(),
577
- error: message,
578
- };
579
- this.#recordMessage(actor, failed);
580
- item.reject?.(new Error(message));
581
- }
582
- finally {
583
- // Retain a durable copy of the run's event log + status in the
584
- // actor's directory so agents.log / /fabric log can inspect what the
585
- // actor sent to and received from its model, even after a successful
586
- // run cleans up the in-memory handle and tmp run directory. Failed
587
- // runs stay in the subagent registry for agents.status(lastRunId).
588
- if (runId) {
589
- await this.#retainRunLog(actor, runId).catch(() => undefined);
590
636
  }
591
- // Release the in-memory handle and tmp run dir for completed runs;
592
- // failed runs are retained for agents.status(actor.lastRunId).
593
- if (previousRunId && previousRunId !== runId) {
594
- await this.subagents.cleanup(previousRunId).catch(() => ({ cleaned: false }));
637
+ catch (error) {
638
+ const message = error instanceof Error ? error.message : String(error);
639
+ actor.lastError = message;
640
+ const failed = {
641
+ id: randomUUID(),
642
+ actorId: actor.id,
643
+ actorName: actor.name,
644
+ direction: "out",
645
+ source: item.source,
646
+ createdAt: Date.now(),
647
+ error: message,
648
+ };
649
+ this.#recordMessage(actor, failed);
650
+ item.reject?.(new Error(message));
595
651
  }
596
- if (runId && runCompleted) {
597
- await this.subagents.cleanup(runId).catch(() => ({ cleaned: false }));
652
+ finally {
653
+ // Retain a durable copy of the run's event log + status in the
654
+ // actor's directory so agents.log / /fabric log can inspect what the
655
+ // actor sent to and received from its model, even after a successful
656
+ // run cleans up the in-memory handle and tmp run directory. Failed
657
+ // runs stay in the subagent registry for agents.status(lastRunId).
658
+ if (runId) {
659
+ await this.#retainRunLog(actor, runId).catch(() => undefined);
660
+ }
661
+ // Release the in-memory handle and tmp run dir for completed runs;
662
+ // failed runs are retained for agents.status(actor.lastRunId).
663
+ if (previousRunId && previousRunId !== runId) {
664
+ await this.subagents.cleanup(previousRunId).catch(() => ({ cleaned: false }));
665
+ }
666
+ if (runId && runCompleted) {
667
+ await this.subagents.cleanup(runId).catch(() => ({ cleaned: false }));
668
+ }
669
+ delete actor.abortController;
670
+ actor.updatedAt = Date.now();
671
+ if (actor.status !== "stopped")
672
+ actor.status = actor.queue.length > 0 ? "queued" : "idle";
673
+ await this.#publishPresence(actor);
598
674
  }
599
- delete actor.abortController;
600
- actor.updatedAt = Date.now();
601
- if (actor.status !== "stopped")
602
- actor.status = actor.queue.length > 0 ? "queued" : "idle";
603
- await this.#publishPresence(actor);
604
675
  }
605
676
  }
677
+ finally {
678
+ // Mark the drain inactive the moment its loop exits (or throws) so a
679
+ // concurrent #ensureDrain observes `draining === false` and starts a
680
+ // fresh drain instead of stranding a just-enqueued item.
681
+ actor.draining = false;
682
+ }
606
683
  }
607
684
  #runRequest(actor, item) {
608
685
  return {
@@ -678,6 +755,10 @@ export class ActorManager {
678
755
  async #pollMesh() {
679
756
  if (this.#polling || this.#closing || !this.meshConfig.enabled)
680
757
  return;
758
+ // Stop-the-world: do not consume mesh events while halted, so deferred
759
+ // events are preserved and dispatched after the user resumes.
760
+ if (this.#halted)
761
+ return;
681
762
  this.#polling = true;
682
763
  try {
683
764
  const tail = this.mesh.tail(this.#meshOffset, this.meshConfig.maxReadEvents);
@@ -888,6 +969,7 @@ export class ActorManager {
888
969
  ...(typeof record.timeoutMs === "number" ? { timeoutMs: record.timeoutMs } : {}),
889
970
  sessionFile: path.join(this.#actorRoot, record.id, "session.jsonl"),
890
971
  queue: [],
972
+ draining: false,
891
973
  messages: [],
892
974
  createdAt: record.createdAt,
893
975
  updatedAt: Date.now(),