@addai/node 0.28.0 → 0.29.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.
@@ -724,12 +724,17 @@ async function runDesktopCommand(cmd) {
724
724
  break;
725
725
  }
726
726
  case 'desktop_start': {
727
- // Someone pressed start, so this one fails loudly rather than
728
- // deferring quietly the way autostart does. They are owed an answer
729
- // they can act on: which limit, and where to change it.
727
+ // A full machine used to be a hard failure here, which left an entity
728
+ // with nothing to do but ask again and hope. It waits instead: the
729
+ // request keeps its place and the reconciler starts it when a slot
730
+ // frees. Said out loud, because a desktop that is "not starting yet"
731
+ // and one that is broken look identical from the outside.
730
732
  const room = await (0, manager_1.desktopStartAllowed)(await (0, manager_1.listDesktops)(), row.id);
731
- if (!room.allowed)
732
- throw new Error(room.reason);
733
+ if (!room.allowed) {
734
+ await (0, manager_1.setStatus)(row.id, { status: 'queued', status_message: room.reason });
735
+ onLog(`${room.reason}\nWaiting for a free slot — this desktop keeps its place in the queue.\n`);
736
+ break;
737
+ }
733
738
  await (0, manager_1.setStatus)(row.id, { status: 'starting' });
734
739
  await provider.start(row);
735
740
  await (0, manager_1.setStatus)(row.id, { status: 'running', status_message: null });
@@ -35,6 +35,10 @@ export declare function maxConcurrentDesktops(): number;
35
35
  * tracked in a counter. A counter would drift the moment somebody stopped a
36
36
  * container by hand, and drift upward means refusing to start anything. */
37
37
  export declare function runningDesktopCount(rows: DesktopRow[]): Promise<number>;
38
+ /** Oldest waiter first, everything else after. Without this the order is
39
+ * whatever the listing happened to return, which is how a desktop ends up
40
+ * waiting behind one that asked after it. */
41
+ export declare function queueOrder<T extends Pick<DesktopRow, 'status' | 'queued_at'>>(rows: T[]): T[];
38
42
  /** Whether one more may start. Returns the reason when it may not, so the
39
43
  * caller can say something better than "failed". */
40
44
  export declare function desktopStartAllowed(rows: DesktopRow[], excludeId?: string): Promise<{
@@ -43,6 +43,7 @@ exports.clampMaxDesktops = clampMaxDesktops;
43
43
  exports.setMaxConcurrentDesktops = setMaxConcurrentDesktops;
44
44
  exports.maxConcurrentDesktops = maxConcurrentDesktops;
45
45
  exports.runningDesktopCount = runningDesktopCount;
46
+ exports.queueOrder = queueOrder;
46
47
  exports.desktopStartAllowed = desktopStartAllowed;
47
48
  exports.startDesktopManager = startDesktopManager;
48
49
  exports.stopDesktopManager = stopDesktopManager;
@@ -68,6 +69,11 @@ function reconcileAction(row, actual) {
68
69
  || row.status === 'deleting' || row.status === 'pending'
69
70
  || row.status === 'failed')
70
71
  return 'none';
72
+ // Waiting for a slot. The tick retries it every pass; whether there is room
73
+ // yet is decided there, because it depends on every OTHER desktop, not this
74
+ // one. Somebody starting it by hand in the meantime just resolves the wait.
75
+ if (row.status === 'queued')
76
+ return actual?.running ? 'mark_running' : 'start';
71
77
  if (actual === null) {
72
78
  // The row says this exists and it does not. Someone pruned it by hand.
73
79
  return row.status === 'running' || row.status === 'stopped' ? 'mark_failed' : 'none';
@@ -176,6 +182,23 @@ async function runningDesktopCount(rows) {
176
182
  }
177
183
  return count;
178
184
  }
185
+ /** When a desktop asked for its slot, as a sortable number. A waiter whose
186
+ * stamp is missing or unreadable goes to the BACK, not the front: Date.parse
187
+ * returns NaN for those, every NaN comparison is false, and a comparator that
188
+ * returns NaN is treated as 0 — so the unstamped row would quietly keep
189
+ * whatever position the listing gave it and cut in front of real waiters. */
190
+ function waitingSince(row) {
191
+ if (row.status !== 'queued')
192
+ return Number.POSITIVE_INFINITY;
193
+ const at = Date.parse(row.queued_at ?? '');
194
+ return Number.isFinite(at) ? at : Number.POSITIVE_INFINITY;
195
+ }
196
+ /** Oldest waiter first, everything else after. Without this the order is
197
+ * whatever the listing happened to return, which is how a desktop ends up
198
+ * waiting behind one that asked after it. */
199
+ function queueOrder(rows) {
200
+ return [...rows].sort((a, b) => waitingSince(a) - waitingSince(b));
201
+ }
179
202
  /** Whether one more may start. Returns the reason when it may not, so the
180
203
  * caller can say something better than "failed". */
181
204
  async function desktopStartAllowed(rows, excludeId) {
@@ -199,7 +222,7 @@ async function tick() {
199
222
  if (!provider)
200
223
  return; // no engine: nothing to reconcile
201
224
  const rows = await listDesktops();
202
- for (const row of rows) {
225
+ for (const row of queueOrder(rows)) {
203
226
  const actual = await provider.inspect(row);
204
227
  const action = reconcileAction(row, actual);
205
228
  if (action === 'none')
@@ -212,7 +235,13 @@ async function tick() {
212
235
  // failing — nothing is wrong with the desktop, there is just no room.
213
236
  const room = await desktopStartAllowed(rows, row.id);
214
237
  if (!room.allowed) {
215
- await setStatus(row.id, { status: 'stopped', status_message: room.reason });
238
+ // Stay in the queue rather than dropping out of it. A desktop that
239
+ // fell back to 'stopped' here would need asking for all over again,
240
+ // which is exactly what waiting is supposed to save you.
241
+ // queued_at is stamped by the status RPC, which keeps the
242
+ // original on a re-queue — a desktop must not lose its place in
243
+ // the line every time the reconciler looks at it.
244
+ await setStatus(row.id, { status: 'queued', status_message: room.reason });
216
245
  continue;
217
246
  }
218
247
  await provider.start(row);
@@ -13,8 +13,11 @@ export interface DesktopRow {
13
13
  enabled_skills: string[];
14
14
  enabled_packages: string[];
15
15
  autostart: boolean;
16
- status: 'pending' | 'creating' | 'starting' | 'running' | 'stopped' | 'failed' | 'deleting';
16
+ status: 'pending' | 'creating' | 'starting' | 'running' | 'stopped' | 'failed' | 'deleting' | 'queued';
17
17
  status_message: string | null;
18
+ /** Set while this desktop is waiting for a slot on a full machine. The node
19
+ * serves its queue oldest-first, so this is the place in the line. */
20
+ queued_at?: string | null;
18
21
  vnc_port: number | null;
19
22
  vnc_password: string | null;
20
23
  }
package/dist/tui/run.js CHANGED
@@ -91,6 +91,9 @@ function createConsole(build, hooks = {}) {
91
91
  function onKeypress(_s, key) {
92
92
  void app.handleKey(key);
93
93
  }
94
+ function onStdinError() {
95
+ teardown();
96
+ }
94
97
  function attachInput() {
95
98
  if (inputAttached)
96
99
  return;
@@ -99,12 +102,20 @@ function createConsole(build, hooks = {}) {
99
102
  process.stdin.setRawMode(true);
100
103
  process.stdin.resume();
101
104
  process.stdin.on('keypress', onKeypress);
105
+ // A terminal can be taken away mid-read: an ssh session drops, a window
106
+ // closes. The read then fails with EIO, and an 'error' with no listener is
107
+ // a thrown exception in node — the process dies without ever running
108
+ // teardown, so raw mode is never turned off and the shell that inherits
109
+ // the terminal reports its own "error on TTY read" afterwards. Treat it as
110
+ // what it is: the terminal is gone, so put it back and leave quietly.
111
+ process.stdin.on('error', onStdinError);
102
112
  inputAttached = true;
103
113
  }
104
114
  function detachInput() {
105
115
  if (!inputAttached)
106
116
  return;
107
117
  process.stdin.off('keypress', onKeypress);
118
+ process.stdin.off('error', onStdinError);
108
119
  if (process.stdin.isTTY)
109
120
  process.stdin.setRawMode(false);
110
121
  process.stdin.pause();
@@ -142,6 +153,16 @@ function createConsole(build, hooks = {}) {
142
153
  draw();
143
154
  }
144
155
  };
156
+ // Backgrounding a TUI is not an error, but reading the terminal from a
157
+ // background process group earns SIGTTIN, and the default action is to STOP
158
+ // the process — which is what "zsh: suspended (tty input)" is. Stopped mid-
159
+ // draw it still holds the terminal in raw mode, so the session it was
160
+ // sharing degrades too. Stop reading and leave instead; the daemon is a
161
+ // separate process and keeps running either way.
162
+ process.on('SIGTTIN', teardown);
163
+ process.on('SIGTTOU', teardown);
164
+ // The terminal went away without the read failing first.
165
+ process.on('SIGHUP', teardown);
145
166
  app.replace(build(app, suspend));
146
167
  return {
147
168
  host: app,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@addai/node",
3
- "version": "0.28.0",
3
+ "version": "0.29.1",
4
4
  "description": "Daemon that pairs a machine with your +Ai account and runs Claude / Codex / Kimi / Gemini agents on its behalf. Reachable via Supabase from Vault, Entity Studio, or any other +Ai surface.",
5
5
  "license": "MIT",
6
6
  "keywords": [