@kdonev/termscape 0.1.7 → 0.1.9

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.
package/dist/hub.js CHANGED
@@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs';
4
4
  import { encodeInjection, INJECT_SUBMIT, MAX_PENDING_PROPOSALS, parseAddress, slugify, } from './protocol/index.js';
5
5
  import { openDb } from './db/index.js';
6
6
  import { Store } from './db/store.js';
7
- import { briefMode, ProfileRegistry } from './agents/profiles.js';
7
+ import { briefMode, isPlainTerminal, ProfileRegistry } from './agents/profiles.js';
8
8
  import { AgentDetector } from './agents/detect.js';
9
9
  import { TemplateRegistry, overlayCanvasTemplates, validate as validateTemplate, } from './agents/templates.js';
10
10
  import { TokenRegistry } from './agents/tokens.js';
@@ -18,9 +18,10 @@ import { PeerRegistry } from './remote/registry.js';
18
18
  import { SPAWN_RELAY_TIMEOUT_MS } from './remote/peer-serve.js';
19
19
  import { deploy } from './remote/deployer.js';
20
20
  import { hubTarballPath } from './remote/tarball.js';
21
+ import { debug } from './debug.js';
21
22
  import { paths } from './paths.js';
22
23
  import { checkFolder, folderName } from './folders.js';
23
- export const HUB_VERSION = '0.1.7';
24
+ export const HUB_VERSION = '0.1.9';
24
25
  /**
25
26
  * How long changes are pooled before every attached machine is told the
26
27
  * canvas has moved on. A status change is a session change, and agents change
@@ -33,6 +34,17 @@ const ANNOUNCE_COALESCE_MS = 300;
33
34
  */
34
35
  const OPENING_QUIET_MS = 1500;
35
36
  const OPENING_READY_CAP_MS = 20_000;
37
+ /**
38
+ * How long a delivery waits for a starting agent before going in regardless.
39
+ *
40
+ * There has to be a bound. A session's readiness gate deliberately outlasts
41
+ * its own cap while a question is on screen - the instruction is still wanted
42
+ * once the human answers - but a `send_message` that blocks its caller until
43
+ * somebody walks over to a window is worse than a message that arrives badly.
44
+ * So the wait is bounded here, and past it delivery is what it always was:
45
+ * immediate, recorded, and the CLI's business.
46
+ */
47
+ const DELIVERY_WAIT_CAP_MS = 20_000;
36
48
  /** Colours cycled through when a workspace is created, for canvas grouping. */
37
49
  const WORKSPACE_COLORS = [
38
50
  '#7c9cf5',
@@ -84,6 +96,16 @@ export class Hub extends EventEmitter {
84
96
  * two roles a null check rather than a flag.
85
97
  */
86
98
  uplink = null;
99
+ /**
100
+ * Per session: resolves once its CLI is reading and its opening instruction,
101
+ * if it had one, has been typed and sent.
102
+ *
103
+ * Every delivery to a local session queues behind this. Without it a message
104
+ * sent to an agent that was still starting - which is exactly what an agent
105
+ * that has just called `spawn_agent` does next - was written into a program
106
+ * not yet reading, and sat in the composer unsent forever (issue 27).
107
+ */
108
+ opened = new Map();
87
109
  announceTimer = null;
88
110
  /** Live SSH tunnels, keyed by host id. Not persisted: they die with the hub. */
89
111
  tunnels = new Map();
@@ -120,7 +142,11 @@ export class Hub extends EventEmitter {
120
142
  this.sessions = new SessionManager(this.store, this.profiles, this.tokens, 'http://127.0.0.1:0');
121
143
  this.router = new MessageRouter(this.store, this.sessions, this.profiles, (m) => this.emit('message', m));
122
144
  this.sessions.on('session', (s) => this.emit('session', s));
123
- this.sessions.on('removed', (id, address) => this.emit('removed', id, address));
145
+ this.sessions.on('removed', (id, address) => {
146
+ // Nothing left to open, and nothing left to hold a delivery for.
147
+ this.opened.delete(id);
148
+ this.emit('removed', id, address);
149
+ });
124
150
  this.sessions.on('data', (id, chunk) => this.emit('data', id, chunk));
125
151
  this.peers = new PeerRegistry(this.store, HUB_VERSION, (address) => this.sessions.getByAddress(address)?.id ?? null);
126
152
  // Peer windows share the canvas, so a new local window must not land on
@@ -249,6 +275,19 @@ export class Hub extends EventEmitter {
249
275
  this.uplink?.agents().find((a) => a.address === address);
250
276
  return found ? { address, parentAddress: found.parentAddress ?? null } : null;
251
277
  }
278
+ /**
279
+ * Whether the window at `address` is a shell rather than an agent, as far
280
+ * as this hub can tell. Profiles are judged against this hub's own
281
+ * registry, which holds every built-in one; a custom profile only another
282
+ * machine declares reads as an agent, which is what it was before.
283
+ */
284
+ isShellAt(address) {
285
+ const profile = this.sessions.getByAddress(address)?.profile ??
286
+ this.peers.find(address)?.session.profile ??
287
+ this.uplink?.agents().find((a) => a.address === address)?.profile;
288
+ const p = profile ? this.profiles.get(profile) : null;
289
+ return p ? isPlainTerminal(p) : false;
290
+ }
252
291
  /**
253
292
  * Refuse to act on an address the viewer may not see, in exactly the words
254
293
  * used for one that does not exist: a hidden agent must not be discoverable
@@ -862,8 +901,7 @@ export class Hub extends EventEmitter {
862
901
  const opening = [this.typedBrief(session), opts.prompt]
863
902
  .filter((part) => !!part)
864
903
  .join('\n\n');
865
- if (opening)
866
- void this.deliverOpeningInstruction(session.id, opening);
904
+ this.noteOpening(session.id, opening || null);
867
905
  this.store.setOpeningPrompt(session.id, opts.restorePrompt === undefined ? (opts.prompt ?? null) : opts.restorePrompt);
868
906
  return session;
869
907
  }
@@ -900,9 +938,9 @@ export class Hub extends EventEmitter {
900
938
  */
901
939
  async resumeSession(sessionId) {
902
940
  const session = await this.sessions.resume(sessionId);
903
- const brief = this.typedBrief(session);
904
- if (brief)
905
- void this.deliverOpeningInstruction(session.id, brief);
941
+ // A fresh CLI, so a fresh gate: messages must wait for this one to be
942
+ // reading just as they did when it first started.
943
+ this.noteOpening(session.id, this.typedBrief(session));
906
944
  return session;
907
945
  }
908
946
  /**
@@ -924,8 +962,7 @@ export class Hub extends EventEmitter {
924
962
  const opening = [this.typedBrief(session), this.store.getOpeningPrompt(sessionId)]
925
963
  .filter((part) => !!part)
926
964
  .join('\n\n');
927
- if (opening)
928
- void this.deliverOpeningInstruction(session.id, opening);
965
+ this.noteOpening(session.id, opening || null);
929
966
  return session;
930
967
  }
931
968
  async resumeWorkspace(workspaceId) {
@@ -1408,6 +1445,16 @@ export class Hub extends EventEmitter {
1408
1445
  throw new Error('cannot send a message to yourself');
1409
1446
  this.requireVisible(me, to);
1410
1447
  const result = await this.deliverFrom(me.address, to, text);
1448
+ // A shell never answers: what it prints stays on its own screen, and
1449
+ // reading that screen is the only way to see it - not polling (issue 30).
1450
+ if (this.isShellAt(to)) {
1451
+ return {
1452
+ ...result,
1453
+ note: `${to} is a shell, not an agent: your text was run there as a command, ` +
1454
+ 'without a [from ...] prefix, and nothing will be sent back to you. Call ' +
1455
+ 'read_screen on it to see the output.',
1456
+ };
1457
+ }
1411
1458
  // Only a successful send counts as a question asked - deliverFrom throws
1412
1459
  // on failure, so a message that never arrived does not excuse polling.
1413
1460
  this.pollWatch.noteMessage(me.address, to);
@@ -1478,6 +1525,19 @@ export class Hub extends EventEmitter {
1478
1525
  // records the failed attempt with its reason, which is what keeps the
1479
1526
  // promise that no message is ever dropped silently.
1480
1527
  if (where === 'local' || where === null) {
1528
+ // Behind the target's opening, if it is still starting. This is the one
1529
+ // place delivery is not immediate, and the exception earns itself: the
1530
+ // alternative is writing into a CLI that is not reading, which is not a
1531
+ // delivery at all.
1532
+ const target = this.sessions.getByAddress(to);
1533
+ if (target) {
1534
+ const t0 = Date.now();
1535
+ await this.awaitOpened(target.id);
1536
+ const waited = Date.now() - t0;
1537
+ if (waited > 50) {
1538
+ debug('deliver', `${fromAddr} -> ${to}: held ${waited}ms behind the agent's opening`);
1539
+ }
1540
+ }
1481
1541
  const r = this.router.send(fromAddr, to, text);
1482
1542
  if (!r.delivered)
1483
1543
  throw new Error(r.error ?? 'delivery failed');
@@ -1488,6 +1548,10 @@ export class Hub extends EventEmitter {
1488
1548
  // complete on this side too.
1489
1549
  const id = randomUUID();
1490
1550
  const sentAt = Date.now();
1551
+ debug('deliver', `message ${id} ${fromAddr} -> ${to} (${text.length} chars): ` +
1552
+ (where === 'remote'
1553
+ ? `to the attached machine ${this.peers.hostIdFor(to) ?? '?'}, which types it`
1554
+ : 'up to the canvas, which routes it'));
1491
1555
  try {
1492
1556
  if (where === 'remote')
1493
1557
  await this.peers.deliver(fromAddr, to, text);
@@ -1501,9 +1565,11 @@ export class Hub extends EventEmitter {
1501
1565
  };
1502
1566
  this.store.insertMessage(m);
1503
1567
  this.emit('message', m);
1568
+ debug('deliver', `message ${id}: accepted after ${Date.now() - sentAt}ms`);
1504
1569
  return { delivered: true, to, deliveredAt: m.deliveredAt };
1505
1570
  }
1506
1571
  catch (err) {
1572
+ debug('deliver', `message ${id}: failed - ${err.message}`);
1507
1573
  const m = {
1508
1574
  id, fromAddr, toAddr: to, body: text,
1509
1575
  sentAt, deliveredAt: null, deliveryState: 'failed',
@@ -1579,7 +1645,13 @@ export class Hub extends EventEmitter {
1579
1645
  * gets there.
1580
1646
  */
1581
1647
  const picked = await this.pickSpawnTemplate(me, opts.profile);
1582
- const instruction = opts.prompt ? `[from ${me.address}] ${opts.prompt}` : null;
1648
+ // Attributed, except in a shell, which would run the prefix (issue 30).
1649
+ const childProfile = this.profiles.get(picked.agent);
1650
+ const instruction = !opts.prompt
1651
+ ? null
1652
+ : childProfile && isPlainTerminal(childProfile)
1653
+ ? opts.prompt
1654
+ : `[from ${me.address}] ${opts.prompt}`;
1583
1655
  const opening = [picked.prompt, instruction].filter(Boolean).join('\n\n') || undefined;
1584
1656
  // A `host` naming something beyond this hub's own machine has to go up
1585
1657
  // the link: an attached hub holds rows for nothing but itself, and only
@@ -1705,6 +1777,48 @@ export class Hub extends EventEmitter {
1705
1777
  async deliverOpeningInstruction(sessionId, prompt) {
1706
1778
  await this.typeWhenReady(sessionId, prompt);
1707
1779
  }
1780
+ /**
1781
+ * Record how a session opens, and hold every later delivery behind it.
1782
+ *
1783
+ * Registered whether or not there is anything to type: an agent started with
1784
+ * no instruction at all still has a CLI that is not reading yet, and that is
1785
+ * the case issue 27 was reported for. Not awaited by the caller - the window
1786
+ * appears now - but stored, so the first message to arrive waits for the
1787
+ * same moment the instruction would have.
1788
+ */
1789
+ noteOpening(sessionId, opening) {
1790
+ const done = opening
1791
+ ? this.deliverOpeningInstruction(sessionId, opening)
1792
+ : this.awaitReady(sessionId).then(() => { });
1793
+ // Failures are the gate opening, not the gate jamming: a session that died
1794
+ // on the way up must not hold its own message log hostage.
1795
+ this.opened.set(sessionId, done.catch(() => { }));
1796
+ }
1797
+ /**
1798
+ * Wait for a local session to be ready to be typed at, but not forever.
1799
+ *
1800
+ * Once resolved the promise stays resolved, so this costs a steady-state
1801
+ * delivery a microtask and nothing else.
1802
+ */
1803
+ async awaitOpened(sessionId) {
1804
+ const gate = this.opened.get(sessionId);
1805
+ if (!gate)
1806
+ return;
1807
+ let timer;
1808
+ try {
1809
+ await Promise.race([
1810
+ gate,
1811
+ new Promise((res) => {
1812
+ timer = setTimeout(res, DELIVERY_WAIT_CAP_MS);
1813
+ timer.unref?.();
1814
+ }),
1815
+ ]);
1816
+ }
1817
+ finally {
1818
+ if (timer)
1819
+ clearTimeout(timer);
1820
+ }
1821
+ }
1708
1822
  /**
1709
1823
  * Wait for the CLI to be up, then type. Written straight away the text lands
1710
1824
  * before the program is reading it; the wait is for output to arrive and
@@ -1725,17 +1839,65 @@ export class Hub extends EventEmitter {
1725
1839
  * taken as answers to it. Such a screen is waited out until it changes.
1726
1840
  */
1727
1841
  async typeWhenReady(sessionId, text) {
1842
+ const t0 = Date.now();
1843
+ debug('deliver', `${sessionId} first instruction (${text.length} chars): waiting for the CLI`);
1844
+ const ready = await this.awaitReady(sessionId);
1728
1845
  const pty = this.sessions.pty(sessionId);
1729
- if (!pty)
1846
+ if (!ready || !pty?.running) {
1847
+ debug('deliver', `${sessionId} first instruction dropped after ${Date.now() - t0}ms: ` +
1848
+ (pty?.running ? 'the CLI never drew anything' : 'the CLI exited'));
1849
+ return;
1850
+ }
1851
+ const session = this.sessions.get(sessionId);
1852
+ if (!session)
1730
1853
  return;
1854
+ const mode = this.profiles.get(session.profile)?.inject ?? 'bracketed';
1855
+ debug('deliver', `${sessionId} first instruction: CLI ready after ${Date.now() - t0}ms, typing it`);
1856
+ try {
1857
+ // Awaited, so that a caller holding this session's readiness gate is not
1858
+ // released until the Enter is in: a message injected between the paste
1859
+ // and its Enter is folded into the same prompt.
1860
+ await this.sessions.inject(sessionId, encodeInjection(text, mode), INJECT_SUBMIT);
1861
+ }
1862
+ catch {
1863
+ // The agent died between the readiness check and the write; the message
1864
+ // log already reflects that it never started.
1865
+ }
1866
+ }
1867
+ /**
1868
+ * Wait until the CLI in a session is up and reading, or give up.
1869
+ *
1870
+ * Split out of `typeWhenReady` because typing is not the only thing that has
1871
+ * to wait for this. A message delivered to an agent whose CLI has not
1872
+ * started reading is written into a program that is not listening: the paste
1873
+ * lands in a composer that is not there yet and the Enter goes nowhere, and
1874
+ * what the sender sees is a delivery that was recorded and never acted on.
1875
+ * See `deliverFrom`.
1876
+ */
1877
+ async awaitReady(sessionId) {
1878
+ const pty = this.sessions.pty(sessionId);
1879
+ if (!pty)
1880
+ return false;
1731
1881
  const profile = this.profiles.get(this.sessions.get(sessionId)?.profile ?? '');
1732
1882
  const hint = profile?.askingHint ? new RegExp(profile.askingHint, 'i') : null;
1733
1883
  // A question on screen outlasts any cap: the human answers it when they
1734
1884
  // get to the window, and the instruction is still wanted after that.
1735
1885
  const asking = () => !!hint && hint.test(pty.tailLines(pty.rows));
1736
- const ready = await new Promise((res) => {
1886
+ /*
1887
+ * Quiet is not enough for an agent that reports through hooks. On kid7,
1888
+ * starting straight after an update, Claude Code had drawn its prompt and
1889
+ * gone quiet five seconds in - and was not reading yet: the instruction
1890
+ * typed then lost its Enter, and SessionStart only arrived after the paste.
1891
+ * Its first hook is the agent saying it is up, so for such an agent that
1892
+ * is what readiness waits for, on top of the quiet. The cap still applies,
1893
+ * so an agent whose hooks never run is not held forever.
1894
+ */
1895
+ const wantsHook = !!profile?.mcp && profile.status === 'hooks';
1896
+ const hooked = () => !wantsHook || pty.hooksReported;
1897
+ return new Promise((res) => {
1737
1898
  let settled = false;
1738
1899
  let seen = false;
1900
+ let isQuiet = false;
1739
1901
  let quiet = null;
1740
1902
  const done = (v) => {
1741
1903
  if (settled)
@@ -1746,40 +1908,54 @@ export class Hub extends EventEmitter {
1746
1908
  clearTimeout(quiet);
1747
1909
  pty.off('data', onData);
1748
1910
  pty.off('exit', onExit);
1911
+ pty.off('hook', onHook);
1749
1912
  res(v);
1750
1913
  };
1914
+ const settle = () => {
1915
+ if (asking()) {
1916
+ debug('deliver', `${sessionId} quiet, but a question is on screen; still waiting`);
1917
+ return;
1918
+ }
1919
+ if (!hooked()) {
1920
+ debug('deliver', `${sessionId} quiet, but its hooks have not reported yet; still waiting`);
1921
+ return;
1922
+ }
1923
+ debug('deliver', `${sessionId} ready: no output for ${OPENING_QUIET_MS}ms, hooks reported`);
1924
+ done(true);
1925
+ };
1751
1926
  const onData = () => {
1752
1927
  seen = true;
1928
+ isQuiet = false;
1753
1929
  if (quiet)
1754
1930
  clearTimeout(quiet);
1755
1931
  quiet = setTimeout(() => {
1756
1932
  quiet = null;
1757
- if (!asking())
1758
- done(true);
1933
+ isQuiet = true;
1934
+ settle();
1759
1935
  }, OPENING_QUIET_MS);
1760
1936
  };
1937
+ // A hook that lands after the quiet is what the quiet was waiting for.
1938
+ const onHook = () => {
1939
+ if (isQuiet)
1940
+ settle();
1941
+ };
1761
1942
  const onExit = () => done(false);
1762
1943
  const cap = setTimeout(() => {
1763
- if (!asking())
1764
- done(seen);
1944
+ if (asking())
1945
+ return;
1946
+ debug('deliver', `${sessionId} ${OPENING_READY_CAP_MS}ms cap reached ` +
1947
+ (!seen
1948
+ ? 'with no output at all'
1949
+ : hooked()
1950
+ ? 'while still drawing; typing anyway'
1951
+ : 'and its hooks never reported; typing anyway'));
1952
+ done(seen);
1765
1953
  }, OPENING_READY_CAP_MS);
1766
1954
  cap.unref?.();
1767
1955
  pty.on('data', onData);
1768
1956
  pty.on('exit', onExit);
1957
+ pty.on('hook', onHook);
1769
1958
  });
1770
- if (!ready || !pty.running)
1771
- return;
1772
- const session = this.sessions.get(sessionId);
1773
- if (!session)
1774
- return;
1775
- const mode = this.profiles.get(session.profile)?.inject ?? 'bracketed';
1776
- try {
1777
- this.sessions.inject(sessionId, encodeInjection(text, mode), INJECT_SUBMIT);
1778
- }
1779
- catch {
1780
- // The agent died between the readiness check and the write; the message
1781
- // log already reflects that it never started.
1782
- }
1783
1959
  }
1784
1960
  async readScreen(sessionId, address, lines) {
1785
1961
  const me = this.requireSession(sessionId);
@@ -1790,6 +1966,9 @@ export class Hub extends EventEmitter {
1790
1966
  // readScreenAt: that path also serves peers asking on this hub's behalf
1791
1967
  // for a caller it never authenticated, and the count belongs to the
1792
1968
  // caller's own hub.
1969
+ // Not for a shell: watching its screen is how its output is read at all.
1970
+ if (this.isShellAt(address))
1971
+ return result;
1793
1972
  const note = this.pollWatch.noteRead(me.address, address);
1794
1973
  return note ? { ...result, note } : result;
1795
1974
  }