baxian 1.2.59 → 1.2.60

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 (103) hide show
  1. package/dist/agent/branch.d.ts.map +1 -1
  2. package/dist/agent/branch.js +19 -13
  3. package/dist/agent/branch.js.map +1 -1
  4. package/dist/agent/dispatch-reconciler.d.ts +45 -0
  5. package/dist/agent/dispatch-reconciler.d.ts.map +1 -0
  6. package/dist/agent/dispatch-reconciler.js +513 -0
  7. package/dist/agent/dispatch-reconciler.js.map +1 -0
  8. package/dist/agent/manager.d.ts +89 -8
  9. package/dist/agent/manager.d.ts.map +1 -1
  10. package/dist/agent/manager.js +975 -368
  11. package/dist/agent/manager.js.map +1 -1
  12. package/dist/agent/pane-streamer-manager.d.ts +70 -0
  13. package/dist/agent/pane-streamer-manager.d.ts.map +1 -1
  14. package/dist/agent/pane-streamer-manager.js +487 -5
  15. package/dist/agent/pane-streamer-manager.js.map +1 -1
  16. package/dist/agent/pane-streamer.d.ts +48 -1
  17. package/dist/agent/pane-streamer.d.ts.map +1 -1
  18. package/dist/agent/pane-streamer.js +338 -15
  19. package/dist/agent/pane-streamer.js.map +1 -1
  20. package/dist/agent/preflight.d.ts.map +1 -1
  21. package/dist/agent/preflight.js +18 -8
  22. package/dist/agent/preflight.js.map +1 -1
  23. package/dist/agent/repo-store.d.ts +3 -0
  24. package/dist/agent/repo-store.d.ts.map +1 -1
  25. package/dist/agent/repo-store.js +192 -67
  26. package/dist/agent/repo-store.js.map +1 -1
  27. package/dist/agent/review-transport.d.ts.map +1 -1
  28. package/dist/agent/review-transport.js +4 -2
  29. package/dist/agent/review-transport.js.map +1 -1
  30. package/dist/agent/tmux-probe-poller.d.ts.map +1 -1
  31. package/dist/agent/tmux-probe-poller.js +29 -20
  32. package/dist/agent/tmux-probe-poller.js.map +1 -1
  33. package/dist/agent/tmux.d.ts +79 -32
  34. package/dist/agent/tmux.d.ts.map +1 -1
  35. package/dist/agent/tmux.js +435 -225
  36. package/dist/agent/tmux.js.map +1 -1
  37. package/dist/api/config.d.ts.map +1 -1
  38. package/dist/api/config.js +10 -0
  39. package/dist/api/config.js.map +1 -1
  40. package/dist/api/projects.d.ts.map +1 -1
  41. package/dist/api/projects.js +186 -94
  42. package/dist/api/projects.js.map +1 -1
  43. package/dist/api/tasks.d.ts.map +1 -1
  44. package/dist/api/tasks.js +5 -1
  45. package/dist/api/tasks.js.map +1 -1
  46. package/dist/app.d.ts +2 -0
  47. package/dist/app.d.ts.map +1 -1
  48. package/dist/app.js +1 -0
  49. package/dist/app.js.map +1 -1
  50. package/dist/cli.d.ts.map +1 -1
  51. package/dist/cli.js +11 -3
  52. package/dist/cli.js.map +1 -1
  53. package/dist/config/hot-reload.d.ts.map +1 -1
  54. package/dist/config/hot-reload.js +1 -0
  55. package/dist/config/hot-reload.js.map +1 -1
  56. package/dist/config/loader.d.ts.map +1 -1
  57. package/dist/config/loader.js +48 -3
  58. package/dist/config/loader.js.map +1 -1
  59. package/dist/config/validator.js +3 -0
  60. package/dist/config/validator.js.map +1 -1
  61. package/dist/errors.d.ts +2 -1
  62. package/dist/errors.d.ts.map +1 -1
  63. package/dist/errors.js +3 -1
  64. package/dist/errors.js.map +1 -1
  65. package/dist/event/handlers.d.ts.map +1 -1
  66. package/dist/event/handlers.js +138 -41
  67. package/dist/event/handlers.js.map +1 -1
  68. package/dist/github/pr-conversation-cache.d.ts +41 -0
  69. package/dist/github/pr-conversation-cache.d.ts.map +1 -0
  70. package/dist/github/pr-conversation-cache.js +105 -0
  71. package/dist/github/pr-conversation-cache.js.map +1 -0
  72. package/dist/index.d.ts.map +1 -1
  73. package/dist/index.js +14 -0
  74. package/dist/index.js.map +1 -1
  75. package/dist/shared/constants.d.ts +6 -0
  76. package/dist/shared/constants.d.ts.map +1 -1
  77. package/dist/shared/constants.js +6 -0
  78. package/dist/shared/constants.js.map +1 -1
  79. package/dist/shared/types.d.ts +4 -0
  80. package/dist/shared/types.d.ts.map +1 -1
  81. package/dist/shared/types.js.map +1 -1
  82. package/dist/state/agent-store.d.ts +5 -1
  83. package/dist/state/agent-store.d.ts.map +1 -1
  84. package/dist/state/agent-store.js +29 -15
  85. package/dist/state/agent-store.js.map +1 -1
  86. package/dist/state/lock.d.ts +9 -0
  87. package/dist/state/lock.d.ts.map +1 -1
  88. package/dist/state/lock.js +24 -1
  89. package/dist/state/lock.js.map +1 -1
  90. package/dist/state/task-store.js +2 -2
  91. package/dist/state/task-store.js.map +1 -1
  92. package/dist/terminal/attach.d.ts +10 -1
  93. package/dist/terminal/attach.d.ts.map +1 -1
  94. package/dist/terminal/attach.js +40 -20
  95. package/dist/terminal/attach.js.map +1 -1
  96. package/dist/terminal/stream-ws.d.ts.map +1 -1
  97. package/dist/terminal/stream-ws.js +27 -0
  98. package/dist/terminal/stream-ws.js.map +1 -1
  99. package/dist/web/assets/{index-DfcbGriX.css → index-CwYBVTyn.css} +1 -1
  100. package/dist/web/assets/index-DtnDvjOU.js +16 -0
  101. package/dist/web/index.html +2 -2
  102. package/package.json +1 -1
  103. package/dist/web/assets/index-DskVWe7e.js +0 -13
@@ -1,29 +1,99 @@
1
1
  import type { AgentConfig, HostConfig } from '../shared/index.js';
2
2
  import type { CommandRunner } from './runner.js';
3
+ import { TmuxManager, type TmuxSessionRef, type TtySize } from './tmux.js';
3
4
  import { PaneStreamer, type PaneStreamerOptions } from './pane-streamer.js';
4
5
  export interface PaneStreamerManagerOptions {
5
6
  runnerFactory: (agent: AgentConfig) => CommandRunner;
6
7
  hostResolver?: (agent: AgentConfig) => HostConfig | undefined;
7
8
  streamerDefaults?: PaneStreamerOptions;
8
9
  inputBatchMs?: number;
10
+ geometryTimeoutMs?: number;
11
+ maxPendingGeometryLiveness?: number;
12
+ maxPendingGeometryPerAgent?: number;
13
+ }
14
+ export declare function raceDeadline<T>(p: Promise<T>, ms: number, onDeadline: () => void): Promise<T>;
15
+ interface OwnerState {
16
+ agent: AgentConfig;
17
+ epoch: number;
18
+ fullHolds: number;
19
+ dirty: boolean;
20
+ scanPending: boolean;
21
+ gen: number;
22
+ lastWrittenGen: number | null;
23
+ fullTarget: TtySize | null;
24
+ statusLines: number;
25
+ identity: TmuxSessionRef | null;
26
+ claim: string | null;
27
+ ownerWriteCapability: 'full' | 'legacy' | null;
28
+ legacyUnsettledWrites: number;
29
+ timer: ReturnType<typeof setInterval> | null;
30
+ inFlight: boolean;
31
+ chain: Promise<void>;
32
+ resizeWriteQueued: boolean;
33
+ resizeWritePending: Promise<void> | null;
34
+ readFailing: boolean;
35
+ foreignWarned: boolean;
36
+ tmux: TmuxManager | null;
9
37
  }
10
38
  export declare class PaneStreamerManager {
11
39
  private readonly streamers;
12
40
  private readonly tmuxByAgent;
13
41
  private readonly inputChains;
14
42
  private readonly pendingInput;
43
+ private readonly owners;
44
+ private readonly ownerEpochs;
45
+ private pendingGlobal;
46
+ private readonly pendingByAgent;
15
47
  private readonly runnerFactory;
16
48
  private readonly hostResolver?;
17
49
  private readonly streamerDefaults?;
18
50
  private readonly inputBatchMs;
51
+ private readonly geometryTimeoutMs;
52
+ private readonly followIntervalMs;
53
+ private readonly maxPendingGlobal;
54
+ private readonly maxPendingPerAgent;
19
55
  constructor(opts: PaneStreamerManagerOptions);
56
+ private get settleDeadlineMs();
20
57
  ensure(agent: AgentConfig): PaneStreamer;
21
58
  has(agentId: string): boolean;
22
59
  destroy(agentId: string, opts?: {
23
60
  silent?: boolean;
24
61
  }): Promise<void>;
25
62
  destroyAll(): Promise<void>;
63
+ startupScan(agents: AgentConfig[]): void;
64
+ private ownerOf;
65
+ private ownerTmux;
66
+ private geometryHooksFor;
67
+ private acquireFullHold;
68
+ private syncReconcilerTimer;
69
+ private admitLiveness;
70
+ private registerLiveness;
71
+ private raceGeometryExec;
72
+ private raceProbeStart;
73
+ private readGeometryAdmitted;
74
+ private enqueueGeometry;
75
+ private issueOwnerWrite;
76
+ private fullBasis;
77
+ reconcileOnce(owner: OwnerState): Promise<void>;
78
+ private commitOwnerResize;
79
+ _geometryPendingForTest(): {
80
+ global: number;
81
+ byAgent: Map<string, number>;
82
+ };
83
+ _ownerForTest(agentId: string): {
84
+ fullHolds: number;
85
+ dirty: boolean;
86
+ gen: number;
87
+ fullTarget: TtySize | null;
88
+ ownerWriteCapability: 'full' | 'legacy' | null;
89
+ claim: string | null;
90
+ scanPending: boolean;
91
+ timerActive: boolean;
92
+ epoch: number;
93
+ } | null;
94
+ _reconcileNowForTest(agentId: string): Promise<void>;
26
95
  private readonly windowFlush;
27
96
  enqueueInput(agentId: string, data: string): Promise<void>;
28
97
  }
98
+ export {};
29
99
  //# sourceMappingURL=pane-streamer-manager.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"pane-streamer-manager.d.ts","sourceRoot":"","sources":["../../src/agent/pane-streamer-manager.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAClE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEjD,OAAO,EACL,YAAY,EACZ,KAAK,mBAAmB,EACzB,MAAM,oBAAoB,CAAC;AAE5B,MAAM,WAAW,0BAA0B;IACzC,aAAa,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,aAAa,CAAC;IACrD,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,UAAU,GAAG,SAAS,CAAC;IAC9D,gBAAgB,CAAC,EAAE,mBAAmB,CAAC;IACvC,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAUD,qBAAa,mBAAmB;IAC9B,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAmC;IAC7D,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAkC;IAC9D,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAoC;IAChE,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAiC;IAE9D,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAwC;IACtE,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAC,CAAiD;IAC/E,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAC,CAAsB;IACxD,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAS;gBAE1B,IAAI,EAAE,0BAA0B;IAO5C,MAAM,CAAC,KAAK,EAAE,WAAW,GAAG,YAAY;IAcxC,GAAG,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO;IAKvB,OAAO,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,GAAE;QAAE,MAAM,CAAC,EAAE,OAAO,CAAA;KAAO,GAAG,OAAO,CAAC,IAAI,CAAC;IAkBxE,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC;IAOjC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAoC;IAEhE,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAgD3D"}
1
+ {"version":3,"file":"pane-streamer-manager.d.ts","sourceRoot":"","sources":["../../src/agent/pane-streamer-manager.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAClE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AACjD,OAAO,EAEL,WAAW,EAEX,KAAK,cAAc,EACnB,KAAK,OAAO,EAEb,MAAM,WAAW,CAAC;AACnB,OAAO,EACL,YAAY,EAEZ,KAAK,mBAAmB,EAGzB,MAAM,oBAAoB,CAAC;AAE5B,MAAM,WAAW,0BAA0B;IACzC,aAAa,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,aAAa,CAAC;IACrD,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,UAAU,GAAG,SAAS,CAAC;IAC9D,gBAAgB,CAAC,EAAE,mBAAmB,CAAC;IACvC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,0BAA0B,CAAC,EAAE,MAAM,CAAC;IACpC,0BAA0B,CAAC,EAAE,MAAM,CAAC;CACrC;AAUD,wBAAgB,YAAY,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC,CAAC,CAAC,CAe7F;AAQD,UAAU,UAAU;IAClB,KAAK,EAAE,WAAW,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,OAAO,CAAC;IAIf,WAAW,EAAE,OAAO,CAAC;IACrB,GAAG,EAAE,MAAM,CAAC;IACZ,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,UAAU,EAAE,OAAO,GAAG,IAAI,CAAC;IAC3B,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,cAAc,GAAG,IAAI,CAAC;IAChC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,oBAAoB,EAAE,MAAM,GAAG,QAAQ,GAAG,IAAI,CAAC;IAC/C,qBAAqB,EAAE,MAAM,CAAC;IAC9B,KAAK,EAAE,UAAU,CAAC,OAAO,WAAW,CAAC,GAAG,IAAI,CAAC;IAC7C,QAAQ,EAAE,OAAO,CAAC;IAClB,KAAK,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC;IACrB,iBAAiB,EAAE,OAAO,CAAC;IAC3B,kBAAkB,EAAE,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IACzC,WAAW,EAAE,OAAO,CAAC;IACrB,aAAa,EAAE,OAAO,CAAC;IACvB,IAAI,EAAE,WAAW,GAAG,IAAI,CAAC;CAC1B;AAED,qBAAa,mBAAmB;IAC9B,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAmC;IAC7D,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAkC;IAC9D,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAoC;IAChE,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAiC;IAC9D,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAiC;IAGxD,OAAO,CAAC,QAAQ,CAAC,WAAW,CAA6B;IACzD,OAAO,CAAC,aAAa,CAAK;IAC1B,OAAO,CAAC,QAAQ,CAAC,cAAc,CAA6B;IAE5D,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAwC;IACtE,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAC,CAAiD;IAC/E,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAC,CAAsB;IACxD,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAS;IACtC,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAS;IAC3C,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAS;IAC1C,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAS;IAC1C,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAAS;gBAEhC,IAAI,EAAE,0BAA0B;IAW5C,OAAO,KAAK,gBAAgB,GAE3B;IAED,MAAM,CAAC,KAAK,EAAE,WAAW,GAAG,YAAY;IAkBxC,GAAG,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO;IAKvB,OAAO,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,GAAE;QAAE,MAAM,CAAC,EAAE,OAAO,CAAA;KAAO,GAAG,OAAO,CAAC,IAAI,CAAC;IAqCxE,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC;IAajC,WAAW,CAAC,MAAM,EAAE,WAAW,EAAE,GAAG,IAAI;IAmBxC,OAAO,CAAC,OAAO;IAiCf,OAAO,CAAC,SAAS;IAOjB,OAAO,CAAC,gBAAgB;IAuCxB,OAAO,CAAC,eAAe;IAwBvB,OAAO,CAAC,mBAAmB;IAY3B,OAAO,CAAC,aAAa;IAKrB,OAAO,CAAC,gBAAgB;YAYV,gBAAgB;IAY9B,OAAO,CAAC,cAAc;YAiBR,oBAAoB;IAqBlC,OAAO,CAAC,eAAe;YAeT,eAAe;IAuC7B,OAAO,CAAC,SAAS;IAOX,aAAa,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC;YA0FvC,iBAAiB;IA4B/B,uBAAuB,IAAI;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;KAAE;IAI3E,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG;QAC9B,SAAS,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,OAAO,CAAC;QAAC,GAAG,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,OAAO,GAAG,IAAI,CAAC;QAC3E,oBAAoB,EAAE,MAAM,GAAG,QAAQ,GAAG,IAAI,CAAC;QAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;QACrE,WAAW,EAAE,OAAO,CAAC;QAAC,WAAW,EAAE,OAAO,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;KAC3D,GAAG,IAAI;IAgBR,oBAAoB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAOpD,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAoC;IAEhE,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAgD3D"}
@@ -1,20 +1,68 @@
1
- import { TmuxManager } from './tmux.js';
2
- import { PaneStreamer, } from './pane-streamer.js';
1
+ import { SessionAbsentError, TmuxManager, contentArea, } from './tmux.js';
2
+ import { PaneStreamer, normalizeFollowIntervalMs, } from './pane-streamer.js';
3
3
  const DEFAULT_INPUT_BATCH_MS = 10;
4
+ const DEFAULT_GEOMETRY_TIMEOUT_MS = 3000;
5
+ // Runner timeout only SIGTERMs; SIGKILL follows after a 2000ms grace and the
6
+ // promise settles on close. The geometry layer must not inherit that tail.
7
+ const GEOMETRY_SETTLE_SLACK_MS = 3000;
8
+ const DEFAULT_MAX_PENDING_GLOBAL = 16;
9
+ const DEFAULT_MAX_PENDING_PER_AGENT = 4;
10
+ export function raceDeadline(p, ms, onDeadline) {
11
+ return new Promise((resolve, reject) => {
12
+ let done = false;
13
+ const timer = setTimeout(() => {
14
+ if (done)
15
+ return;
16
+ done = true;
17
+ try {
18
+ onDeadline();
19
+ }
20
+ catch { }
21
+ reject(new Error(`geometry settle deadline (${ms}ms) exceeded`));
22
+ }, ms);
23
+ timer.unref?.();
24
+ p.then((v) => { if (!done) {
25
+ done = true;
26
+ clearTimeout(timer);
27
+ resolve(v);
28
+ } }, (e) => { if (!done) {
29
+ done = true;
30
+ clearTimeout(timer);
31
+ reject(e);
32
+ } });
33
+ });
34
+ }
4
35
  export class PaneStreamerManager {
5
36
  streamers = new Map();
6
37
  tmuxByAgent = new Map();
7
38
  inputChains = new Map();
8
39
  pendingInput = new Map();
40
+ owners = new Map();
41
+ // Epochs outlive owner-state deletion: a deleted-and-recreated agent id must
42
+ // never validate closures/tasks captured against its previous life.
43
+ ownerEpochs = new Map();
44
+ pendingGlobal = 0;
45
+ pendingByAgent = new Map();
9
46
  runnerFactory;
10
47
  hostResolver;
11
48
  streamerDefaults;
12
49
  inputBatchMs;
50
+ geometryTimeoutMs;
51
+ followIntervalMs;
52
+ maxPendingGlobal;
53
+ maxPendingPerAgent;
13
54
  constructor(opts) {
14
55
  this.runnerFactory = opts.runnerFactory;
15
56
  this.hostResolver = opts.hostResolver;
16
57
  this.streamerDefaults = opts.streamerDefaults;
17
58
  this.inputBatchMs = opts.inputBatchMs ?? DEFAULT_INPUT_BATCH_MS;
59
+ this.geometryTimeoutMs = opts.geometryTimeoutMs ?? DEFAULT_GEOMETRY_TIMEOUT_MS;
60
+ this.followIntervalMs = normalizeFollowIntervalMs(opts.streamerDefaults?.windowFollowIntervalMs);
61
+ this.maxPendingGlobal = opts.maxPendingGeometryLiveness ?? DEFAULT_MAX_PENDING_GLOBAL;
62
+ this.maxPendingPerAgent = opts.maxPendingGeometryPerAgent ?? DEFAULT_MAX_PENDING_PER_AGENT;
63
+ }
64
+ get settleDeadlineMs() {
65
+ return this.geometryTimeoutMs + GEOMETRY_SETTLE_SLACK_MS;
18
66
  }
19
67
  ensure(agent) {
20
68
  const existing = this.streamers.get(agent.id);
@@ -25,7 +73,11 @@ export class PaneStreamerManager {
25
73
  ? this.hostResolver(agent)
26
74
  : (typeof agent.host === 'object' ? agent.host : undefined));
27
75
  const tmux = new TmuxManager(runner);
28
- const streamer = new PaneStreamer(agent, tmux, resolveHost, this.streamerDefaults);
76
+ const owner = this.ownerOf(agent);
77
+ const streamer = new PaneStreamer(agent, tmux, resolveHost, {
78
+ ...this.streamerDefaults,
79
+ geometry: this.geometryHooksFor(owner),
80
+ });
29
81
  this.streamers.set(agent.id, streamer);
30
82
  this.tmuxByAgent.set(agent.id, tmux);
31
83
  return streamer;
@@ -52,13 +104,443 @@ export class PaneStreamerManager {
52
104
  this.streamers.delete(agentId);
53
105
  if (streamer)
54
106
  streamer.destroy(opts);
107
+ // Agent removal discards functional owner state; any queued/in-flight geometry
108
+ // task or hold-release closure from the old epoch lands as a no-op, and remote
109
+ // residue stays inert behind the session-identity fence. Raw liveness entries
110
+ // in the global registry survive until their own settle.
111
+ const owner = this.owners.get(agentId);
112
+ if (owner) {
113
+ owner.epoch++;
114
+ this.ownerEpochs.set(agentId, owner.epoch);
115
+ if (owner.timer) {
116
+ clearInterval(owner.timer);
117
+ owner.timer = null;
118
+ }
119
+ // silent = planned shutdown (destroyAll): boot-time startupScan owns any
120
+ // leftover reconciliation, so a dirty owner is not worth a warning there.
121
+ if (owner.dirty && !opts.silent) {
122
+ console.warn(`[pane-streamer-manager] ${agentId} removed while owner state dirty; a rebuilt agent starts clean`);
123
+ }
124
+ this.owners.delete(agentId);
125
+ }
55
126
  }
56
127
  async destroyAll() {
57
- const ids = [...this.streamers.keys()];
58
- await Promise.all(ids.map((id) => this.destroy(id, { silent: true }).catch((err) => {
128
+ const ids = new Set([...this.streamers.keys(), ...this.owners.keys()]);
129
+ await Promise.all([...ids].map((id) => this.destroy(id, { silent: true }).catch((err) => {
59
130
  console.error(`[PaneStreamerManager] destroyAll: destroy ${id} failed:`, err);
60
131
  })));
61
132
  }
133
+ // Boot-time pass: process restarts lose in-memory owner state; one reconcile
134
+ // round per configured agent heals any manual left behind by a prior life.
135
+ // Sequential (bounded queue): a parallel fan-out would eat the global admission
136
+ // budget and deterministically starve agents past the cap. scanPending is the
137
+ // retry credential — a transient/backpressured round keeps it, the per-owner
138
+ // timer retries until the round succeeds or the session is provably absent.
139
+ startupScan(agents) {
140
+ const queue = [];
141
+ for (const agent of agents) {
142
+ const owner = this.ownerOf(agent);
143
+ owner.scanPending = true;
144
+ this.syncReconcilerTimer(owner);
145
+ queue.push(owner);
146
+ }
147
+ void (async () => {
148
+ for (const owner of queue) {
149
+ try {
150
+ await this.reconcileOnce(owner);
151
+ }
152
+ catch (err) {
153
+ console.warn(`[pane-streamer-manager] startup scan round failed for ${owner.agent.id}:`, err);
154
+ }
155
+ }
156
+ })();
157
+ }
158
+ ownerOf(agent) {
159
+ const existing = this.owners.get(agent.id);
160
+ if (existing) {
161
+ existing.agent = agent;
162
+ return existing;
163
+ }
164
+ const owner = {
165
+ agent,
166
+ epoch: this.ownerEpochs.get(agent.id) ?? 0,
167
+ fullHolds: 0,
168
+ dirty: false,
169
+ scanPending: false,
170
+ gen: 0,
171
+ lastWrittenGen: null,
172
+ fullTarget: null,
173
+ statusLines: 1,
174
+ identity: null,
175
+ claim: null,
176
+ ownerWriteCapability: null,
177
+ legacyUnsettledWrites: 0,
178
+ timer: null,
179
+ inFlight: false,
180
+ chain: Promise.resolve(),
181
+ resizeWriteQueued: false,
182
+ resizeWritePending: null,
183
+ readFailing: false,
184
+ foreignWarned: false,
185
+ tmux: null,
186
+ };
187
+ this.owners.set(agent.id, owner);
188
+ return owner;
189
+ }
190
+ ownerTmux(owner) {
191
+ const live = this.tmuxByAgent.get(owner.agent.id);
192
+ if (live)
193
+ return live;
194
+ if (!owner.tmux)
195
+ owner.tmux = new TmuxManager(this.runnerFactory(owner.agent));
196
+ return owner.tmux;
197
+ }
198
+ geometryHooksFor(owner) {
199
+ const epoch = owner.epoch;
200
+ const agentId = owner.agent.id;
201
+ const current = () => {
202
+ const cur = this.owners.get(agentId);
203
+ return cur && cur.epoch === epoch ? cur : null;
204
+ };
205
+ return {
206
+ fullHolds: () => current()?.fullHolds ?? 0,
207
+ acquireFullHold: () => {
208
+ const cur = current();
209
+ if (!cur)
210
+ return () => undefined;
211
+ return this.acquireFullHold(cur);
212
+ },
213
+ readGeometry: async () => {
214
+ const cur = current();
215
+ if (!cur)
216
+ throw new Error(`agent ${agentId} owner state replaced`);
217
+ return this.readGeometryAdmitted(cur);
218
+ },
219
+ raceProbe: (start) => this.raceProbeStart(agentId, start),
220
+ noteManualSeen: () => {
221
+ const cur = current();
222
+ if (!cur || cur.fullHolds > 0)
223
+ return;
224
+ cur.dirty = true;
225
+ this.syncReconcilerTimer(cur);
226
+ void this.reconcileOnce(cur);
227
+ },
228
+ recordFullTarget: (size) => {
229
+ const cur = current();
230
+ if (cur)
231
+ cur.fullTarget = size;
232
+ },
233
+ commitOwnerResize: () => {
234
+ const cur = current();
235
+ if (!cur)
236
+ return Promise.reject(new Error(`agent ${agentId} owner state replaced; resize dropped`));
237
+ return this.commitOwnerResize(cur);
238
+ },
239
+ };
240
+ }
241
+ acquireFullHold(owner) {
242
+ owner.fullHolds++;
243
+ if (owner.fullHolds === 1) {
244
+ owner.dirty = true;
245
+ this.syncReconcilerTimer(owner);
246
+ void this.reconcileOnce(owner);
247
+ }
248
+ const epoch = owner.epoch;
249
+ let released = false;
250
+ return () => {
251
+ if (released)
252
+ return;
253
+ released = true;
254
+ const cur = this.owners.get(owner.agent.id);
255
+ if (!cur || cur.epoch !== epoch)
256
+ return;
257
+ cur.fullHolds = Math.max(0, cur.fullHolds - 1);
258
+ if (cur.fullHolds === 0) {
259
+ cur.fullTarget = null;
260
+ cur.dirty = true;
261
+ this.syncReconcilerTimer(cur);
262
+ void this.reconcileOnce(cur);
263
+ }
264
+ };
265
+ }
266
+ syncReconcilerTimer(owner) {
267
+ const needed = this.owners.get(owner.agent.id) === owner
268
+ && (owner.dirty || owner.fullHolds > 0 || owner.scanPending);
269
+ if (needed && !owner.timer) {
270
+ owner.timer = setInterval(() => { void this.reconcileOnce(owner); }, this.followIntervalMs);
271
+ owner.timer.unref?.();
272
+ }
273
+ else if (!needed && owner.timer) {
274
+ clearInterval(owner.timer);
275
+ owner.timer = null;
276
+ }
277
+ }
278
+ admitLiveness(agentId) {
279
+ const per = this.pendingByAgent.get(agentId) ?? 0;
280
+ return this.pendingGlobal < this.maxPendingGlobal && per < this.maxPendingPerAgent;
281
+ }
282
+ registerLiveness(agentId, settled) {
283
+ this.pendingGlobal++;
284
+ this.pendingByAgent.set(agentId, (this.pendingByAgent.get(agentId) ?? 0) + 1);
285
+ const release = () => {
286
+ this.pendingGlobal = Math.max(0, this.pendingGlobal - 1);
287
+ const next = (this.pendingByAgent.get(agentId) ?? 1) - 1;
288
+ if (next <= 0)
289
+ this.pendingByAgent.delete(agentId);
290
+ else
291
+ this.pendingByAgent.set(agentId, next);
292
+ };
293
+ settled.then(release, release);
294
+ }
295
+ async raceGeometryExec(agentId, start) {
296
+ if (!this.admitLiveness(agentId)) {
297
+ throw new Error(`geometry liveness capacity reached (${agentId}); backing off`);
298
+ }
299
+ const raw = start();
300
+ this.registerLiveness(agentId, raw.then(() => undefined, () => undefined));
301
+ return raceDeadline(raw, this.settleDeadlineMs, () => undefined);
302
+ }
303
+ // Admission is reserved BEFORE the probe live-body exists (lazy start): a
304
+ // capacity-refused probe creates nothing, and a created probe is registered
305
+ // before it can race — no PTY ever lives outside the global registry.
306
+ raceProbeStart(agentId, start) {
307
+ if (!this.admitLiveness(agentId)) {
308
+ return Promise.reject(new Error(`geometry liveness capacity reached (${agentId}); probe not admitted`));
309
+ }
310
+ const handle = start();
311
+ this.registerLiveness(agentId, handle.settled.then(() => undefined, () => undefined));
312
+ return raceDeadline(handle.result, this.settleDeadlineMs, () => {
313
+ try {
314
+ handle.onDeadline();
315
+ }
316
+ catch (err) {
317
+ // The registry entry stays until the probe's own exit is observed — the
318
+ // occupied slot IS the retry credential; the operator just needs to know.
319
+ console.warn(`[pane-streamer-manager] ${agentId} capability probe kill failed (slot stays occupied until it exits):`, err);
320
+ }
321
+ });
322
+ }
323
+ async readGeometryAdmitted(owner) {
324
+ const tmux = this.ownerTmux(owner);
325
+ const geom = await this.raceGeometryExec(owner.agent.id, () => tmux.getWindowGeometry(owner.agent.id, { timeout: this.geometryTimeoutMs }));
326
+ owner.statusLines = geom.statusLines;
327
+ if (owner.identity && (owner.identity.sessionId !== geom.ref.sessionId
328
+ || owner.identity.serverPid !== geom.ref.serverPid
329
+ || owner.identity.serverStart !== geom.ref.serverStart)) {
330
+ // A rebuilt session has no relation to the previous full target or writes.
331
+ owner.fullTarget = null;
332
+ owner.lastWrittenGen = null;
333
+ }
334
+ owner.identity = geom.ref;
335
+ owner.claim = geom.claim;
336
+ owner.ownerWriteCapability = geom.ownerWriteCapability;
337
+ if (geom.ownerGen !== null && geom.ownerGen > owner.gen)
338
+ owner.gen = geom.ownerGen;
339
+ return geom;
340
+ }
341
+ enqueueGeometry(owner, epoch, task) {
342
+ const run = owner.chain.then(async () => {
343
+ const cur = this.owners.get(owner.agent.id);
344
+ if (cur !== owner || owner.epoch !== epoch) {
345
+ throw new Error(`agent ${owner.agent.id} owner state replaced; geometry task dropped`);
346
+ }
347
+ return task();
348
+ });
349
+ owner.chain = run.then(() => undefined, () => undefined);
350
+ return run;
351
+ }
352
+ // Every owner write is server-side guarded by session triple ∧ claim; 'full'
353
+ // capability adds the numeric generation compare. 'null' capability (unproven
354
+ // either way) issues NOTHING — fail closed, retry after the next read.
355
+ async issueOwnerWrite(owner, mode, resizeTo) {
356
+ if (owner.identity === null) {
357
+ throw new Error(`agent ${owner.agent.id} owner identity unknown; no write issued`);
358
+ }
359
+ if (owner.claim !== owner.agent.id) {
360
+ throw new Error(`agent ${owner.agent.id} session claim is ${JSON.stringify(owner.claim)}; refusing to write to a foreign/unclaimed session`);
361
+ }
362
+ const capability = owner.ownerWriteCapability;
363
+ if (capability === null) {
364
+ throw new Error(`agent ${owner.agent.id} owner write capability unproven; no write issued`);
365
+ }
366
+ const tmux = this.ownerTmux(owner);
367
+ const opts = { timeout: this.geometryTimeoutMs };
368
+ if (capability === 'full') {
369
+ const gen = owner.gen = Math.max(Date.now(), owner.gen + 1);
370
+ await this.raceGeometryExec(owner.agent.id, () => tmux.ownerWrite(owner.agent.id, owner.identity, owner.agent.id, gen, mode, resizeTo, opts));
371
+ owner.lastWrittenGen = gen;
372
+ return;
373
+ }
374
+ // Legacy server (<3.2, no format arithmetic): triple ∧ claim guard still holds;
375
+ // only the generation compare is unavailable, so same-session out-of-order
376
+ // writes remain possible. dirty must not clear while one is unsettled.
377
+ await this.raceGeometryExec(owner.agent.id, () => {
378
+ owner.legacyUnsettledWrites++;
379
+ const raw = tmux.ownerWrite(owner.agent.id, owner.identity, owner.agent.id, null, mode, resizeTo, opts);
380
+ const dec = () => { owner.legacyUnsettledWrites = Math.max(0, owner.legacyUnsettledWrites - 1); };
381
+ raw.then(dec, dec);
382
+ return raw;
383
+ });
384
+ owner.lastWrittenGen = null;
385
+ }
386
+ fullBasis(owner) {
387
+ if (owner.fullTarget)
388
+ return owner.fullTarget;
389
+ const streamer = this.streamers.get(owner.agent.id);
390
+ if (streamer && !streamer.isDestroyed())
391
+ return streamer.size;
392
+ return null;
393
+ }
394
+ async reconcileOnce(owner) {
395
+ if (owner.inFlight)
396
+ return;
397
+ owner.inFlight = true;
398
+ try {
399
+ if (this.owners.get(owner.agent.id) !== owner)
400
+ return;
401
+ if (!owner.dirty && owner.fullHolds === 0 && !owner.scanPending)
402
+ return;
403
+ const epoch = owner.epoch;
404
+ let geom;
405
+ try {
406
+ geom = await this.readGeometryAdmitted(owner);
407
+ if (owner.readFailing) {
408
+ owner.readFailing = false;
409
+ console.log(`[pane-streamer-manager] ${owner.agent.id} owner geometry read recovered`);
410
+ }
411
+ }
412
+ catch (err) {
413
+ if (err instanceof SessionAbsentError) {
414
+ // A PROVEN-absent session has nothing to reconcile; a rebuilt one starts
415
+ // at tmux defaults and is re-armed by ensure()/startupScan(). Transient
416
+ // failures (SSH flake, exit 255, admission backpressure) land in the
417
+ // other branch and keep every retry credential.
418
+ owner.scanPending = false;
419
+ if (owner.fullHolds === 0 && owner.dirty)
420
+ owner.dirty = false;
421
+ this.syncReconcilerTimer(owner);
422
+ return;
423
+ }
424
+ if (!owner.readFailing) {
425
+ owner.readFailing = true;
426
+ const detail = err instanceof Error ? err.message : String(err);
427
+ console.warn(`[pane-streamer-manager] ${owner.agent.id} owner geometry read failing: ${detail}`);
428
+ }
429
+ return;
430
+ }
431
+ if (owner.epoch !== epoch || this.owners.get(owner.agent.id) !== owner)
432
+ return;
433
+ // The read succeeded: the boot-scan credential is spent regardless of what
434
+ // the state machine decides next (dirty carries any follow-up work).
435
+ owner.scanPending = false;
436
+ if (geom.claim !== owner.agent.id) {
437
+ if (!owner.foreignWarned) {
438
+ owner.foreignWarned = true;
439
+ console.warn(`[pane-streamer-manager] ${owner.agent.id} same-name session is claimed by ${JSON.stringify(geom.claim)}; owner writes withheld`);
440
+ }
441
+ this.syncReconcilerTimer(owner);
442
+ return;
443
+ }
444
+ if (owner.foreignWarned) {
445
+ owner.foreignWarned = false;
446
+ console.log(`[pane-streamer-manager] ${owner.agent.id} session claim recovered`);
447
+ }
448
+ const desired = owner.fullHolds > 0 ? 'manual' : 'latest';
449
+ const basis = desired === 'manual' ? this.fullBasis(owner) : null;
450
+ const wantArea = basis ? contentArea(basis, owner.statusLines) : null;
451
+ const modeOk = geom.sizeMode === desired;
452
+ const genOk = owner.ownerWriteCapability === 'full'
453
+ ? (owner.lastWrittenGen === null || geom.ownerGen === owner.lastWrittenGen)
454
+ : true;
455
+ const sizeOk = desired === 'latest' || wantArea === null
456
+ || (geom.width === wantArea.cols && geom.height === wantArea.rows);
457
+ if (modeOk && genOk && sizeOk) {
458
+ if (owner.legacyUnsettledWrites === 0 && owner.dirty) {
459
+ owner.dirty = false;
460
+ }
461
+ this.syncReconcilerTimer(owner);
462
+ return;
463
+ }
464
+ owner.dirty = true;
465
+ this.syncReconcilerTimer(owner);
466
+ try {
467
+ await this.enqueueGeometry(owner, epoch, async () => {
468
+ const mode = owner.fullHolds > 0 ? 'manual' : 'latest';
469
+ const commitBasis = mode === 'manual' ? this.fullBasis(owner) : null;
470
+ const resizeTo = mode === 'manual' && commitBasis
471
+ ? contentArea(commitBasis, owner.statusLines)
472
+ : undefined;
473
+ if (mode === 'manual' && !commitBasis && geom.sizeMode === 'manual')
474
+ return;
475
+ await this.issueOwnerWrite(owner, mode, resizeTo);
476
+ });
477
+ }
478
+ catch (err) {
479
+ const detail = err instanceof Error ? err.message : String(err);
480
+ console.warn(`[pane-streamer-manager] ${owner.agent.id} owner write failed (will retry): ${detail}`);
481
+ }
482
+ }
483
+ finally {
484
+ owner.inFlight = false;
485
+ }
486
+ }
487
+ async commitOwnerResize(owner) {
488
+ owner.dirty = true;
489
+ this.syncReconcilerTimer(owner);
490
+ const epoch = owner.epoch;
491
+ if (owner.identity === null || owner.ownerWriteCapability === null) {
492
+ await this.readGeometryAdmitted(owner);
493
+ if (owner.epoch !== epoch || this.owners.get(owner.agent.id) !== owner) {
494
+ throw new Error(`agent ${owner.agent.id} owner state replaced; resize dropped`);
495
+ }
496
+ }
497
+ if (owner.resizeWriteQueued && owner.resizeWritePending)
498
+ return owner.resizeWritePending;
499
+ owner.resizeWriteQueued = true;
500
+ const run = this.enqueueGeometry(owner, epoch, async () => {
501
+ owner.resizeWriteQueued = false;
502
+ // Released or retargeted while queued: the reconciler owns 'latest'; a newer
503
+ // resize target is picked up here because we read fullTarget at run time.
504
+ if (owner.fullHolds === 0)
505
+ return;
506
+ const target = owner.fullTarget;
507
+ if (!target)
508
+ return;
509
+ await this.issueOwnerWrite(owner, 'manual', contentArea(target, owner.statusLines));
510
+ });
511
+ const shared = run.finally(() => {
512
+ if (owner.resizeWritePending === shared)
513
+ owner.resizeWritePending = null;
514
+ });
515
+ owner.resizeWritePending = shared;
516
+ return shared;
517
+ }
518
+ _geometryPendingForTest() {
519
+ return { global: this.pendingGlobal, byAgent: new Map(this.pendingByAgent) };
520
+ }
521
+ _ownerForTest(agentId) {
522
+ const o = this.owners.get(agentId);
523
+ if (!o)
524
+ return null;
525
+ return {
526
+ fullHolds: o.fullHolds,
527
+ dirty: o.dirty,
528
+ gen: o.gen,
529
+ fullTarget: o.fullTarget,
530
+ ownerWriteCapability: o.ownerWriteCapability,
531
+ claim: o.claim,
532
+ scanPending: o.scanPending,
533
+ timerActive: o.timer !== null,
534
+ epoch: o.epoch,
535
+ };
536
+ }
537
+ _reconcileNowForTest(agentId) {
538
+ const owner = this.owners.get(agentId);
539
+ if (!owner)
540
+ return Promise.resolve();
541
+ owner.scanPending = true;
542
+ return this.reconcileOnce(owner);
543
+ }
62
544
  windowFlush = new Map();
63
545
  enqueueInput(agentId, data) {
64
546
  if (data.length === 0)