@ctrl-spc/cs 0.6.0 → 0.7.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.
package/dist/daemon.js CHANGED
@@ -1,23 +1,53 @@
1
1
  import { ensureAutostart } from './autostart.js';
2
2
  import { startPresence, stopPresence } from './presence.js';
3
+ /* ═══ THE ONE IMPORT ANYTHING OUTSIDE `panel3/` MAKES INTO IT. ═══ Named in
4
+ `.implementations/19-agent-panel-v3/conventions.md` and enforced by
5
+ `test/panel3-isolation.contract.test.mjs`, whose MOUNTS map allows this file
6
+ exactly this specifier. A second one fails the suite. See `startPanel`. */
7
+ import { startPanel } from './panel3/run.js';
3
8
  /**
4
9
  * The terminal presence daemon (`cs start`). Comes online and heartbeats until
5
10
  * the process is signalled. Under launchd/KeepAlive (autostart) a hard crash is
6
11
  * restarted by the OS. The companion server (`cs open`) shares the same presence
7
12
  * loop via presence.ts, so the two front-ends never diverge.
13
+ *
14
+ * ═══ AND SINCE recovery-1 SLICE 4 IT ALSO ANSWERS AGENT PANEL CARDS. ═══ Lane's
15
+ * ruling (2026-08-21): *the user experience must never require them to launch or
16
+ * authenticate multiple CLIs. They launch the CLI with `cs start`.* Before this
17
+ * the panel was a second binary with a second sign-in, so a stranded card could
18
+ * name the right machine and print a command that started the wrong daemon.
19
+ *
20
+ * THE PANEL GETS THE SESSION THIS ALREADY HOLDS, never one of its own. Two
21
+ * clients in one process is two refresh loops on one rotating refresh token,
22
+ * both writing `session.json`.
23
+ *
24
+ * ═══ THE COMPANION DELIBERATELY DOES NOT DO THIS. ═══ Two panel loops on one
25
+ * machine id and one harness contend on a single `panel3_machines` row and each
26
+ * would sweep the other's live runs. `cs start` is the launch the ruling names,
27
+ * and `ensureAutostart` below installs exactly it, so an existing login item
28
+ * starts doing the right thing with nothing else typed.
8
29
  */
9
30
  export async function runDaemon() {
10
31
  ensureAutostart(); // default-on: install the login item unless the user opted out
11
- const { machineName, agents } = await startPresence();
32
+ const { machineName, agents, client } = await startPresence();
33
+ const panel = startPanel(client);
12
34
  console.log(`CTRL+SPC — this computer: ${machineName}`);
13
35
  console.log(`Agents detected: ${agents.length ? agents.join(', ') : 'none'}`);
14
- console.log('Online. Heartbeating presence. Ctrl-C to stop.');
36
+ console.log('Online. Heartbeating presence and answering cards. Ctrl-C to stop.');
15
37
  let stopping = false;
16
38
  async function shutdown() {
17
39
  if (stopping)
18
40
  return;
19
41
  stopping = true;
20
- await stopPresence({ markOffline: true });
42
+ /* ═══ ONE HANDLER, BOTH WRITES, AND THEN ONE EXIT. ═══ A machine that has
43
+ gone has to read gone in both places: `cliv2_agents` for the title bar's
44
+ machines chip, and `panel3_machines.stopped_at` for the card that would
45
+ otherwise go on saying an agent is working. Two handlers each calling
46
+ `process.exit` would let whichever finished first kill the other's write,
47
+ so the panel's own signal handling stands down when it is started from
48
+ here (see `run()`). Both are best-effort and neither throws, which is why
49
+ they can settle together. */
50
+ await Promise.all([panel.stop(), stopPresence({ markOffline: true })]);
21
51
  process.exit(0);
22
52
  }
23
53
  process.on('SIGINT', () => void shutdown());
package/dist/env.js CHANGED
@@ -10,6 +10,48 @@ export const SUPABASE_KEY = process.env.CTRL_SPC_SUPABASE_KEY || 'sb_publishable
10
10
  export const HEARTBEAT_INTERVAL_MS = 10_000;
11
11
  /** Command (ping) poll cadence. */
12
12
  export const COMMAND_POLL_INTERVAL_MS = 3_000;
13
+ /** Orchestrator listener cadence (feature 16, Slice 3). The interval a designated
14
+ * machine waits before looking for a new loose todo — and therefore the worst
15
+ * case for ux.md mock state E step 1, "Working with no agent named. Present for
16
+ * at most one poll interval". Matched to the command poll: both are one small
17
+ * indexed read against the user's own rows, and a second cadence to reason
18
+ * about would buy nothing. Not faster — the panel is realtime, so the user sees
19
+ * their own todo appear instantly; this interval only bounds how long before an
20
+ * agent is NAMED on it. */
21
+ export const ORCHESTRATOR_POLL_INTERVAL_MS = 3_000;
22
+ /** !Cleanup Phase 7 Slice 1 (I21, I24) — HOW MANY AGENTS MAY RUN AT ONCE.
23
+ *
24
+ * Was structurally ONE (a boolean `busy` on the listener state), which is the
25
+ * defect behind the user's first requirement: a second request typed seconds
26
+ * after the first sat untouched until the first finished, for up to the whole
27
+ * spawn timeout.
28
+ *
29
+ * THREE, and bounded on purpose. Each run is a real headless `claude` process
30
+ * with real memory and real API cost, so a burst of twenty requests must not
31
+ * fork twenty agents. Three is enough that a question is never stuck behind a
32
+ * build — the case the user named — while staying comfortable on a laptop that
33
+ * is also running the user's editor and browser.
34
+ *
35
+ * Overridable per machine, because the right number on a 64 GB desktop is not
36
+ * the right number on a laptop.
37
+ *
38
+ * THE REAL CEILING TURNED OUT NOT TO BE THE MACHINE. The Phase 7 Slice 1 walk
39
+ * ran three concurrent Opus workers and two of them came back `claude exited 1`
40
+ * while the third answered normally — the agent binary was fine when run by
41
+ * hand, and the stream carried a `rate_limit_event` for the five-hour window.
42
+ * So the binding constraint on concurrency is the user's API rate limit, not
43
+ * RAM or CPU, and raising this number past what the account allows converts
44
+ * work that would have queued into work that FAILS and burns attempts. A
45
+ * rate-limited run is currently indistinguishable from a crashed one, which is
46
+ * worth fixing before this default goes any higher. */
47
+ export const ORCHESTRATOR_MAX_CONCURRENT = Number(process.env.CTRL_SPC_V2_MAX_CONCURRENT) || 3;
48
+ /** Hard ceiling on one headless agent run before it is killed and the todo is
49
+ * released for retry. Generous on purpose: the spawned agent does REAL WORK
50
+ * (ux.md § Purpose) — it may cut a worktree, run a build, read a codebase — so a
51
+ * short timeout would kill legitimate work and hand the user a released todo
52
+ * that keeps failing the same way. It exists only so a wedged process cannot
53
+ * hold the single in-flight slot forever. */
54
+ export const ORCHESTRATOR_SPAWN_TIMEOUT_MS = 30 * 60_000;
13
55
  /** How long a work session stays "working" after the agent's last ctrl-spc tool
14
56
  * call, with no further activity. The companion keeps last_seen_at fresh within
15
57
  * this window; once it lapses (agent finished, Ctrl-C'd, crashed, or asleep) the
@@ -0,0 +1,98 @@
1
+ /**
2
+ * 18a SLICE 5: WHAT THE USER READS WHEN AN ATTEMPT FAILS.
3
+ *
4
+ * .implementations/18-orchestrator/18a-you-answer-and-the-work-continues/ux.md
5
+ * § "Slice 5. A failure you did not cause reads like the machine"
6
+ *
7
+ * The daemon's internal error strings are exit statuses: `claude exited 1`,
8
+ * `claude exited ENOENT`, `codex produced no output`. They are the right thing
9
+ * to LOG, and they are what every existing test asserts on, so they are left
10
+ * exactly as they are. What was wrong is that the same string reached the card.
11
+ *
12
+ * An exit code is not an explanation. It is identical whether the binary is
13
+ * missing, the account is rate limited, or the agent genuinely crashed, and the
14
+ * user cannot act on any of them from the number. This translates at the point
15
+ * the reason is STORED, so the log keeps the diagnosis and the card gets the
16
+ * sentence.
17
+ *
18
+ * THE FALLBACK IS HONEST, not a guess. An error this does not recognise becomes
19
+ * "the agent stopped without finishing", which is true of every one of them, and
20
+ * the technical string is still in the daemon log for whoever needs it. Inventing
21
+ * a cause from an exit code would be the same defect wearing better words.
22
+ */
23
+ /** How many times the orchestrator dispatches before giving up.
24
+ * `MAX_ATTEMPTS` in orchestrator.ts is the definition; this module does not
25
+ * need it, but the web app's copy of the number is documented against the same
26
+ * fact: it is deliberately not configurable, so a constant cannot drift into a
27
+ * lie the way a configurable one would. */
28
+ /**
29
+ * The agent's name as the user picked it in the machine popover.
30
+ *
31
+ * Shared rather than inlined because 18a Slice 7 composes its own sentence: the
32
+ * liveness watch does not have an error string to translate, it has a FACT (the
33
+ * process is gone), and a sentence written from that fact is more honest than
34
+ * one matched out of a string nobody wrote.
35
+ *
36
+ * An agent this does not know about is passed through rather than mangled.
37
+ */
38
+ export function agentDisplayName(agent) {
39
+ return agent === 'claude' ? 'Claude' : agent === 'codex' ? 'Codex' : agent;
40
+ }
41
+ /**
42
+ * Plain words for a failed attempt, from the daemon's own error string plus
43
+ * whatever the child printed to stderr.
44
+ *
45
+ * `agent` is capitalised into the sentence because the user picked it by name
46
+ * ("Claude", "Codex") and a sentence about "the agent" reads as being about
47
+ * something they did not choose.
48
+ */
49
+ export function plainFailureReason(agent, error, stderr = '') {
50
+ const name = agentDisplayName(agent);
51
+ const haystack = `${error}\n${stderr}`.toLowerCase();
52
+ /* THE COMMAND IS NOT THERE. `ENOENT` from spawn is the fixture the scenario
53
+ uses (the agent removed from PATH) and the single most likely real cause on
54
+ a fresh machine. It is also the one the user can fix in a minute, which is
55
+ why it is worth naming rather than folding into the fallback. */
56
+ if (haystack.includes('enoent') || haystack.includes('command not found')) {
57
+ return `${name} is not installed on this machine, or is not on its PATH.`;
58
+ }
59
+ /* RATE LIMITED. ux.md records the measurement this came from: on 2026-08-07
60
+ two of three concurrent runs died on the account's rate limit while the same
61
+ work succeeded alone. It is the reason the cap stays at three, and until now
62
+ it was indistinguishable from a crash. */
63
+ if (haystack.includes('rate limit') || haystack.includes('rate_limit')
64
+ || haystack.includes('429') || haystack.includes('overloaded')) {
65
+ return `${name}'s usage limit is in effect on this account, so the run could not start.`;
66
+ }
67
+ /* SIGNED OUT. Recoverable by the user, and silent otherwise: the run just
68
+ fails, over and over, for as long as the session is stale.
69
+
70
+ 18a SLICE 7 ADDED THE OAUTH AND 401 SIGNATURES, from the real thing rather
71
+ than from imagination. The Windows machine's Claude Code had a stale token
72
+ and said, on STDOUT as a stream-json result: `"error":
73
+ "authentication_failed"` and `API Error: 401 OAuth access token has
74
+ expired.` The word "authentication" above would have matched that, but only
75
+ once stdout was being read at all, which is the other half of the fix. `401`
76
+ and `oauth` are here so the same failure is caught when it arrives in
77
+ shorter words. */
78
+ if (haystack.includes('unauthorized') || haystack.includes('not logged in')
79
+ || haystack.includes('authentication') || haystack.includes('oauth')
80
+ || haystack.includes('401')) {
81
+ return `${name} is not signed in on this machine.`;
82
+ }
83
+ /* THE RUN WAS KILLED, by the timeout or by a signal from outside. Not the
84
+ user's Stop, which never reaches here: a stopped run is settled with its own
85
+ flag well before this. */
86
+ if (haystack.includes('timed out')) {
87
+ return `The run was stopped for taking too long.`;
88
+ }
89
+ if (haystack.includes('killed by')) {
90
+ return `${name} was killed on this machine before it finished.`;
91
+ }
92
+ /* IT RAN AND SAID NOTHING. A clean exit with no answer, which is a real state
93
+ and reads very differently from a crash. */
94
+ if (haystack.includes('produced no output')) {
95
+ return `${name} finished without producing an answer.`;
96
+ }
97
+ return `${name} stopped without finishing.`;
98
+ }
package/dist/index.js CHANGED
@@ -11,7 +11,7 @@ const HELP = `cs — CTRL+SPC
11
11
  cs Open the Companion app (the front door)
12
12
  cs open Open the Companion app in your browser
13
13
  cs login Sign in from the terminal and link this computer
14
- cs start Come online now, no window (used by auto-start)
14
+ cs start Come online and answer cards, no window (used by auto-start)
15
15
  cs status Show sign-in state, computer, and detected agents
16
16
  cs autostart on Come online automatically at login
17
17
  cs autostart off Stop coming online at login