@ctrl-spc/cs 0.7.12 → 0.7.13

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,11 +1,5 @@
1
1
  import { ensureAutostart } from './autostart.js';
2
- import { claimDaemonLock, releaseDaemonLock } from './daemon-lock.js';
3
2
  import { liveClient, startPresence, stopPresence } from './presence.js';
4
- /* ═══ THE ONE IMPORT ANYTHING OUTSIDE `panel3/` MAKES INTO IT. ═══ Named in
5
- `.implementations/19-agent-panel-v3/conventions.md` and enforced by
6
- `test/panel3-isolation.contract.test.mjs`, whose MOUNTS map allows this file
7
- exactly this specifier. A second one fails the suite. See `startPanel`. */
8
- import { startPanel } from './panel3/run.js';
9
3
  /**
10
4
  * The terminal presence daemon (`cs start`). Comes online and heartbeats until
11
5
  * the process is signalled. Under launchd/KeepAlive (autostart) a hard crash is
@@ -22,89 +16,12 @@ import { startPanel } from './panel3/run.js';
22
16
  * clients in one process is two refresh loops on one rotating refresh token,
23
17
  * both writing `session.json`.
24
18
  *
25
- * ═══ THE COMPANION DELIBERATELY DOES NOT DO THIS. ═══ Two panel loops on one
26
- * machine id and one harness contend on a single `panel3_machines` row and each
27
- * would sweep the other's live runs. `cs start` is the launch the ruling names,
28
- * and `ensureAutostart` below installs exactly it, so an existing login item
29
- * starts doing the right thing with nothing else typed.
19
+ * Companion and the terminal daemon both use the same presence lifecycle,
20
+ * which owns the card worker, session and per-machine lock.
30
21
  */
31
22
  export async function runDaemon() {
32
- /* One daemon per config dir. See daemon-lock.ts for the flicker this stops.
33
- Exits rather than replacing the holder: under launchd's KeepAlive a
34
- replacement would be restarted and would then replace the replacer. */
35
- const lock = claimDaemonLock();
36
- if (lock.held) {
37
- console.error(`cs start is already running on this computer (pid ${lock.pid}). Stop it first to start another.`);
38
- process.exitCode = 1;
39
- return;
40
- }
41
- ensureAutostart(); // default-on: install the login item unless the user opted out
42
23
  const { machineName, agents } = await startPresence();
43
- /* ═══ THE PANEL READS PRESENCE'S CLIENT, IT IS NOT HANDED A COPY OF IT. ═══
44
- `startPresence` used to return the client it had just built and this line
45
- passed that value to `startPanel`, which held it for the process's life. The
46
- heartbeat's catch block REBUILDS that client when `session.json` comes to
47
- hold a different refresh token, and since the rebuild guard landed a
48
- successful rebuild is the normal path rather than a throw. The value handed
49
- over here was therefore a client the database refuses and nobody watches,
50
- while the panel's two-second poll went on writing `panel3_machines` with it.
51
- That table took 37,051 writes in the incident window, the largest single
52
- share of it.
53
- `liveClient` is presence's own accessor, so the panel asks the module that
54
- owns the presence lifecycle rather than being told once. It cannot answer
55
- null here: `startPresence` has returned, so presence is running. */
56
- /* ═══ AND THE LAST CLIENT IT SAW IS KEPT, FOR THE ONE READ THAT HAPPENS AFTER
57
- PRESENCE HAS ALREADY STOPPED. ═══ `startPanel` returns `stop: () =>
58
- stopListening(current(), ...)`, and `current()` is evaluated in the arrow,
59
- BEFORE `stopListening` is entered, so `stopListening`'s own best-effort
60
- try/catch cannot cover it. On `SIGNED_OUT` both watchers are bound to this
61
- same client and auth-js runs them in registration order: presence's fires
62
- first and `stopPresence` sets its module singleton to null synchronously,
63
- so by the time the daemon's watcher reaches `shutdown` -> `panel.stop()`
64
- the getter below has nothing live to answer with. Throwing there is a throw
65
- inside `shutdown` before its first await, which `void shutdown(1)` does not
66
- contain: `panel3_machines.stopped_at` never gets written, the card goes on
67
- saying an agent is working, `Promise.all` rejects, `process.exit` is never
68
- reached, and the panel's `for(;;)` spins on a caught-and-logged failure
69
- every two seconds forever. launchd's `KeepAlive` cannot help a process that
70
- never exits. `shutdown()` must reach `process.exit` on every path.
71
-
72
- THE FALLBACK IS THE LAST CLIENT PRESENCE ITSELF HELD, not the one it built
73
- at startup: a rebuild replaces `p.client`, and a goodbye written with the
74
- pre-rebuild snapshot is the same refused write this branch exists to stop.
75
- Recording it on every successful read is what keeps it current without a
76
- second subscription to the presence lifecycle.
77
-
78
- THE THROW IS NOT WEAKENED FOR THE POLL, and that is the point of ordering
79
- the two lines this way. A poll only reaches the fallback once presence has
80
- stopped, which in this process means a shutdown is already in flight and
81
- the exit is milliseconds away; every other poll, for the whole life of the
82
- daemon, reads a live client or fails loudly exactly as before. What the
83
- fallback buys is the goodbye write, which `stopListening` documents as
84
- best-effort precisely because it is the last thing this process does.
85
-
86
- THE ONE THING THAT WINDOW COSTS, NAMED RATHER THAN LEFT TO BE REDISCOVERED:
87
- a poll landing in it used to throw at the getter and be swallowed by the
88
- per-poll catch, so it could not write at all. Now it takes the fallback and
89
- reaches `sayListening`, whose upsert deliberately sends `stopped_at: null`
90
- on every poll, since that is what lets a restarted daemon come back on a row
91
- stamped as gone. If such a poll's write lands AFTER `stopListening`'s stamp,
92
- it erases the goodbye and the row reads listening again. That is bounded and
93
- self-healing: `LISTENING_WINDOW_MS` is 15s, so the freshness check calls the
94
- machine gone 15 seconds later whatever the column says, and the process is
95
- already exiting. Not worth a second seam to close, and worth knowing about
96
- if a machine is ever seen lingering for a few seconds after `cs start` ends. */
97
- let lastKnownClient = liveClient();
98
- const panel = startPanel(() => {
99
- const live = liveClient();
100
- if (live) {
101
- lastKnownClient = live;
102
- return live;
103
- }
104
- if (!lastKnownClient)
105
- throw new Error('the presence loop is not running, so there is no session to work through');
106
- return lastKnownClient;
107
- });
24
+ ensureAutostart(); // default-on: install the login item unless the user opted out
108
25
  console.log(`CTRL+SPC — this computer: ${machineName}`);
109
26
  console.log(`Agents detected: ${agents.length ? agents.join(', ') : 'none'}`);
110
27
  console.log('Online. Heartbeating presence and answering cards. Ctrl-C to stop.');
@@ -113,46 +30,13 @@ export async function runDaemon() {
113
30
  if (stopping)
114
31
  return;
115
32
  stopping = true;
116
- /* ═══ ONE HANDLER, BOTH WRITES, AND THEN ONE EXIT. ═══ A machine that has
117
- gone has to read gone in both places: `cliv2_agents` for the title bar's
118
- machines chip, and `panel3_machines.stopped_at` for the card that would
119
- otherwise go on saying an agent is working. Two handlers each calling
120
- `process.exit` would let whichever finished first kill the other's write,
121
- so the panel's own signal handling stands down when it is started from
122
- here (see `run()`). Both are best-effort and neither throws, which is why
123
- they can settle together. */
124
- await Promise.all([panel.stop(), stopPresence()]);
125
- releaseDaemonLock();
33
+ // Shared presence stops the card worker and both readiness records.
34
+ await stopPresence();
126
35
  process.exit(code);
127
36
  }
128
37
  process.on('SIGINT', () => void shutdown());
129
38
  process.on('SIGTERM', () => void shutdown());
130
- /* ═══ AND THE PANEL GOES DOWN WITH THE SESSION TOO. ═══ presence.ts stops its
131
- own three loops on `SIGNED_OUT`, which leaves this process holding a panel
132
- that would go on upserting `panel3_machines` every few seconds with a token
133
- the database refuses. It shares this client, so it hears the same event: one
134
- shutdown, both halves, and the exit says the daemon needs a person rather
135
- than pretending it is still online. Non-zero because this is a failure, not
136
- a Ctrl-C. The login item's `KeepAlive` restarts either way, and that is the
137
- point: a restart refuses at `getClient` after one refresh attempt and exits
138
- again, so an unattended machine retries on launchd's throttle instead of
139
- polling four times a second forever. */
140
- /* ═══ BOUND TO WHATEVER CLIENT IS LIVE AT THIS INSTANT, WHICH IS WHY IT IS
141
- READ AND NOT CAPTURED. ═══ This used to bind to the client `startPresence`
142
- returned. A rebuild replaces that client, and presence re-binds its OWN
143
- watcher to the replacement (`bindSignedOutWatcher`) while this one would
144
- stay on the discarded client, where `SIGNED_OUT` can never fire again. That
145
- leaves the daemon's half of the shutdown deaf for the rest of the process.
146
- Reading it here binds to the current one; a client rebuilt LATER is
147
- presence's watcher to carry, and that one calls `stopPresence`, which clears
148
- the intervals this process depends on. This is not a second terminal stop,
149
- it is the same one bound to a client that exists.
150
-
151
- THE NULL IS THROWN ON RATHER THAN SKIPPED, matching the getter above. `?.`
152
- would read as though no client were an expected state, and it is not:
153
- `startPresence` has returned, so presence is running. Worse, it would
154
- silently drop half the shutdown in exactly the state it claims to guard
155
- against, leaving a daemon that answers a dead session by doing nothing. */
39
+ // Presence owns sign-out cleanup; the terminal entry also exits.
156
40
  const live = liveClient();
157
41
  if (!live)
158
42
  throw new Error('the presence loop is not running, so there is no session to watch');
package/dist/folders.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { execFile } from 'node:child_process';
2
- /** Native folder chooser per OS. Same commands the v1 companion uses. */
3
- function pickerCommand() {
4
- if (process.platform === 'darwin') {
2
+ /** Native folder chooser per OS. */
3
+ export function pickerCommand(platform = process.platform) {
4
+ if (platform === 'darwin') {
5
5
  return {
6
6
  command: 'osascript',
7
7
  args: [
@@ -13,13 +13,101 @@ function pickerCommand() {
13
13
  ],
14
14
  };
15
15
  }
16
- if (process.platform === 'win32') {
16
+ if (platform === 'win32') {
17
+ const source = String.raw `
18
+ using System;
19
+ using System.Runtime.InteropServices;
20
+
21
+ [ComImport]
22
+ [Guid("DC1C5A9C-E88A-4DDE-A5A1-60F82A20AEF7")]
23
+ internal class FileOpenDialog { }
24
+
25
+ [ComImport]
26
+ [Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE")]
27
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
28
+ internal interface IShellItem
29
+ {
30
+ void BindToHandler(IntPtr pbc, ref Guid bhid, ref Guid riid, out IntPtr ppv);
31
+ void GetParent(out IShellItem parent);
32
+ void GetDisplayName(uint sigdnName, out IntPtr name);
33
+ void GetAttributes(uint mask, out uint attributes);
34
+ void Compare(IShellItem other, uint hint, out int order);
35
+ }
36
+
37
+ [ComImport]
38
+ [Guid("D57C7288-D4AD-4768-BE02-9D969532D960")]
39
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
40
+ internal interface IFileOpenDialog
41
+ {
42
+ [PreserveSig] int Show(IntPtr owner);
43
+ void SetFileTypes(uint count, IntPtr filters);
44
+ void SetFileTypeIndex(uint index);
45
+ void GetFileTypeIndex(out uint index);
46
+ void Advise(IntPtr events, out uint cookie);
47
+ void Unadvise(uint cookie);
48
+ void SetOptions(uint options);
49
+ void GetOptions(out uint options);
50
+ void SetDefaultFolder(IShellItem folder);
51
+ void SetFolder(IShellItem folder);
52
+ void GetFolder(out IShellItem folder);
53
+ void GetCurrentSelection(out IShellItem item);
54
+ void SetFileName([MarshalAs(UnmanagedType.LPWStr)] string name);
55
+ void GetFileName([MarshalAs(UnmanagedType.LPWStr)] out string name);
56
+ void SetTitle([MarshalAs(UnmanagedType.LPWStr)] string title);
57
+ void SetOkButtonLabel([MarshalAs(UnmanagedType.LPWStr)] string text);
58
+ void SetFileNameLabel([MarshalAs(UnmanagedType.LPWStr)] string label);
59
+ void GetResult(out IShellItem item);
60
+ void AddPlace(IShellItem item, uint alignment);
61
+ void SetDefaultExtension([MarshalAs(UnmanagedType.LPWStr)] string extension);
62
+ void Close(int result);
63
+ void SetClientGuid(ref Guid guid);
64
+ void ClearClientData();
65
+ void SetFilter(IntPtr filter);
66
+ void GetResults(out IntPtr items);
67
+ void GetSelectedItems(out IntPtr items);
68
+ }
69
+
70
+ public static class CtrlSpcFolderPicker
71
+ {
72
+ private const uint PickFolders = 0x20;
73
+ private const uint ForceFileSystem = 0x40;
74
+ private const uint PathMustExist = 0x800;
75
+ private const uint FileSystemPath = 0x80058000;
76
+ private const int Cancelled = unchecked((int)0x800704C7);
77
+
78
+ [DllImport("user32.dll")]
79
+ private static extern IntPtr GetForegroundWindow();
80
+
81
+ public static string Choose()
82
+ {
83
+ IFileOpenDialog dialog = (IFileOpenDialog)new FileOpenDialog();
84
+ IShellItem item = null;
85
+ IntPtr name = IntPtr.Zero;
86
+ try
87
+ {
88
+ uint options;
89
+ dialog.GetOptions(out options);
90
+ dialog.SetOptions(options | PickFolders | ForceFileSystem | PathMustExist);
91
+ dialog.SetTitle("Choose a project folder for CTRL+SPC");
92
+ int result = dialog.Show(GetForegroundWindow());
93
+ if (result == Cancelled) return null;
94
+ Marshal.ThrowExceptionForHR(result);
95
+ dialog.GetResult(out item);
96
+ item.GetDisplayName(FileSystemPath, out name);
97
+ return Marshal.PtrToStringUni(name);
98
+ }
99
+ finally
100
+ {
101
+ if (name != IntPtr.Zero) Marshal.FreeCoTaskMem(name);
102
+ if (item != null) Marshal.FinalReleaseComObject(item);
103
+ Marshal.FinalReleaseComObject(dialog);
104
+ }
105
+ }
106
+ }`;
17
107
  const script = [
18
- 'Add-Type -AssemblyName System.Windows.Forms',
19
- '$dialog = New-Object System.Windows.Forms.FolderBrowserDialog',
20
- '$dialog.Description = "Choose a project folder for CTRL+SPC"',
21
- 'if ($dialog.ShowDialog() -eq "OK") { $dialog.SelectedPath }',
22
- ].join('; ');
108
+ `Add-Type -TypeDefinition @'\n${source}\n'@`,
109
+ '[CtrlSpcFolderPicker]::Choose()',
110
+ ].join('\n');
23
111
  return { command: 'powershell.exe', args: ['-NoProfile', '-STA', '-Command', script] };
24
112
  }
25
113
  return {
@@ -86,6 +86,13 @@ export async function sayListening(client, machineId, name, harness) {
86
86
  if (error)
87
87
  throw new Error(`could not say this machine is listening: ${error.message}`);
88
88
  }
89
+ /** Only a path-free category leaves the machine. The full error stays in its log. */
90
+ export async function sayPollingProblem(client, machineId, harness, problem) {
91
+ const { error } = await client.from('panel3_machines').update({ problem })
92
+ .eq('machine_id', machineId).eq('harness', harness);
93
+ if (error)
94
+ throw new Error(`could not report worker health: ${error.message}`);
95
+ }
89
96
  /**
90
97
  * Stop saying it, because this daemon is going away.
91
98
  *
@@ -327,6 +327,8 @@ export const whatWasAttached = (attachments) => {
327
327
  second place for it to drift. */
328
328
  'A `workflow` is the process this work follows: stages, in an order it gives. The owner of this',
329
329
  'conversation is the one who reads it, with `get_workflow`.',
330
+ 'An `artifact` is a document on a work item. Read it with `get_artifact`, and revise it with',
331
+ '`update_artifact`.',
330
332
  ];
331
333
  return [
332
334
  ...(codebases.length === 0 ? [] : [
@@ -2,7 +2,7 @@
2
2
  * ═══ AGENT PANEL v3: the poll loop that answers a card. ═══
3
3
  *
4
4
  * THIS FILE BELONGS TO AGENT PANEL v3. Nothing outside `src/panel3/` may import
5
- * it, WITH ONE NAMED EXCEPTION: `cli-v2/src/daemon.ts` imports `startPanel` from
5
+ * it, WITH ONE NAMED EXCEPTION: `cli-v2/src/presence.ts` imports `startPanel` from
6
6
  * here and nothing else, which is how `cs start` runs the panel. It is written
7
7
  * down in `conventions.md` and checked by `panel3-isolation.contract.test.mjs`;
8
8
  * `startPanel`'s own comment at the foot of this file carries the reasoning.
@@ -136,7 +136,7 @@ import { readableWriteError } from '../firewall.js';
136
136
  import { answerPrompt, escalationPrompt, levelOnePrompt, ownerActivationPrompt, readBackPrompt, landingOutcomeContext, presentedArtifactAnswerContext, resumePrompt, retryPrompt, standingRules, whatWasAttached, workingRules, OWNER_COMPLETION_RULES, } from './prompt.js';
137
137
  import { ASK_CONTENT_COLUMNS, attachmentLine, gitRulesFor, loadAttachments, loadOutputNames, outputOf, recordBaseProtection, standingRulesFor, withAskContent, } from './show.js';
138
138
  import { forgetSecrets, redactSecrets } from './secrets.js';
139
- import { sayListening, stopListening } from './presence.js';
139
+ import { sayListening, sayPollingProblem, stopListening } from './presence.js';
140
140
  import { selectedHarness } from './coordinator.js';
141
141
  import { baseBranchState, checkoutForCodebase, commitCardWork, detectBaseProtection, folderIsBranch, hasCheckoutForCodebase, mergeIntoBase, releaseBaseBranch, settleCardWorktree, worktreeForCard, worktreesOnThisMachine, } from './checkout.js';
142
142
  import { harness, startAgent } from './spawn.js';
@@ -2980,7 +2980,7 @@ export async function sweepFinishedWorktrees(client) {
2980
2980
  export function clientReader(injected) {
2981
2981
  return typeof injected === 'function' ? injected : () => injected;
2982
2982
  }
2983
- export async function run(args, injected, signal) {
2983
+ export async function run(args, injected, signal, lifecycle) {
2984
2984
  let once = false;
2985
2985
  for (const arg of args) {
2986
2986
  if (arg === '--once')
@@ -3078,6 +3078,9 @@ export async function run(args, injected, signal) {
3078
3078
  }
3079
3079
  try {
3080
3080
  while (!signal?.aborted) {
3081
+ // Restart only at a poll boundary, before any claims, with no live work.
3082
+ if (lifecycle && !lifecycle.beforePoll(inFlight.size))
3083
+ break;
3081
3084
  /* ═══ ONE POLL FAILING IS NOT THE DAEMON FAILING. ═══ Every read and write
3082
3085
  here throws on a network or database error, by design (constraint 7), and
3083
3086
  until Slice 4 that threw straight out of `panel3/cli.js run` and exited the process.
@@ -3188,6 +3191,8 @@ export async function run(args, injected, signal) {
3188
3191
  // rather than becoming an unhandled rejection.
3189
3192
  hold(runId, answerCard(current(), tools, machineId, cardId, turns));
3190
3193
  }
3194
+ await sayPollingProblem(current(), machineId, machineHarness, null);
3195
+ lifecycle?.ready();
3191
3196
  if (once) {
3192
3197
  /* ═══ UNTIL NOTHING IS LEFT, NOT ONCE OVER WHAT WAS THERE. ═══ A level 1
3193
3198
  run dispatches WHILE it is being waited on, so the child appears in
@@ -3203,6 +3208,14 @@ export async function run(args, injected, signal) {
3203
3208
  if (once)
3204
3209
  throw error;
3205
3210
  said(`this poll did not finish: ${error instanceof Error ? error.message : String(error)}`);
3211
+ if (listeningHarness !== null) {
3212
+ try {
3213
+ await sayPollingProblem(current(), machineId, listeningHarness, 'poll_failed');
3214
+ }
3215
+ catch (reportError) {
3216
+ said(`worker health could not be reported: ${reportError instanceof Error ? reportError.message : String(reportError)}`);
3217
+ }
3218
+ }
3206
3219
  }
3207
3220
  if (!signal?.aborted)
3208
3221
  await sleep(POLL_INTERVAL_MS);
@@ -3219,52 +3232,67 @@ export async function run(args, injected, signal) {
3219
3232
  }
3220
3233
  }
3221
3234
  // ---------------------------------------------------------------------------
3222
- /**
3223
- * ═══ THE ONE THING `cs start` REACHES INTO `panel3/` FOR. ═══
3224
- *
3225
- * recovery-1 Slice 4, and Lane's ruling behind it (2026-08-21): *the user
3226
- * experience must never require them to launch or authenticate multiple CLIs.
3227
- * They launch the CLI with `cs start`.* Slices 1 to 3 print `cs start` on a
3228
- * stranded card and, until this function existed, that command started a daemon
3229
- * which polled no cards at all — the sentence named the right machine and the
3230
- * wrong command, and no rewording could fix it.
3231
- *
3232
- * ═══ IT IS A NAMED EXCEPTION TO THE ISOLATION RULE, NOT A HOLE IN IT. ═══
3233
- * `conventions.md` forbids anything outside `panel3/` importing anything inside
3234
- * it, and `panel3-isolation.contract.test.mjs` enforces it. `cli-v2/src/daemon.ts`
3235
- * is listed there with exactly ONE allowed specifier, `./panel3/run.js`, which is
3236
- * why this is a single entry point handing back a single closure rather than
3237
- * three exports the daemon would have to assemble. A second specifier fails the
3238
- * suite, which is the point: the rule is enumerated, never weakened.
3239
- *
3240
- * ═══ THE CLIENT IS THE CALLER'S, AND THAT IS THE WHOLE OF "ONE SIGN-IN". ═══
3241
- * `startPresence()` owns the client it built from the session `cs login` stored.
3242
- * This must never take one of its own: two clients in one process is two refresh
3243
- * loops on one rotating refresh token, both writing `session.json` through
3244
- * `getClient`'s `onAuthStateChange`, which is the hazard `client.ts` describes
3245
- * moved indoors.
3246
- *
3247
- * ═══ AND IT IS READ RATHER THAN HANDED OVER, BECAUSE PRESENCE REPLACES IT. ═══
3248
- * A `SupabaseClient` is still accepted, for the harness and for any caller
3249
- * holding exactly one. `cs start` passes a function instead, because presence's
3250
- * heartbeat rebuilds its client when the account on disk changes, and the panel
3251
- * holding the pre-rebuild one is the largest single share of the poll storm this
3252
- * widening closes. `stop` reads it through the same getter for the same reason:
3253
- * it is the last write this process makes, minutes or days after the getter was
3254
- * handed over, and a goodbye written with a refused token leaves the card saying
3255
- * an agent is working.
3256
- *
3257
- * ═══ IT DOES NOT BLOCK, AND IT DOES NOT TAKE THE DAEMON DOWN. ═══ The loop
3258
- * never returns, so awaiting it here would hang `cs start` before it printed a
3259
- * line. A throw that escapes the per-poll guard inside it is the panel stopping,
3260
- * which is said on the one stream a person reads and leaves v2 presence
3261
- * heartbeating rather than killing the process around it.
3262
- */
3235
+ /** Shared presence owns this supervisor and supplies its current authenticated
3236
+ * client. A stopped worker is restarted automatically. Explicit restarts happen
3237
+ * at an idle poll boundary and confirm only after a new worker completes a poll.
3238
+ * Sign-out stops the supervisor, so it cannot restart behind the user's back. */
3263
3239
  export function startPanel(injected) {
3264
3240
  const controller = new AbortController();
3265
- const running = run([], injected, controller.signal).catch((error) => {
3266
- said(`the agent panel stopped polling: ${error instanceof Error ? error.message : String(error)}`);
3267
- });
3268
- // The loop owns its current presence row, including shutdown after a switch.
3269
- return { stop: async () => { controller.abort(); await running; } };
3241
+ let requested = false;
3242
+ let restarting = null;
3243
+ let resolveRestart = null;
3244
+ let rejectRestart = null;
3245
+ const running = (async () => {
3246
+ while (!controller.signal.aborted) {
3247
+ try {
3248
+ await run([], injected, controller.signal, {
3249
+ beforePoll: (active) => {
3250
+ if (!requested)
3251
+ return true;
3252
+ requested = false;
3253
+ if (active > 0) {
3254
+ rejectRestart?.(new Error('Work is still running on this machine. Stop the affected cards before restarting the worker.'));
3255
+ return true;
3256
+ }
3257
+ return false;
3258
+ },
3259
+ ready: () => { if (!requested)
3260
+ resolveRestart?.(); },
3261
+ });
3262
+ }
3263
+ catch (error) {
3264
+ said(`the agent panel stopped polling: ${error instanceof Error ? error.message : String(error)}`);
3265
+ // Startup failures must not leave an online machine with a dead worker.
3266
+ if (!controller.signal.aborted)
3267
+ await sleep(POLL_INTERVAL_MS);
3268
+ }
3269
+ }
3270
+ })();
3271
+ return {
3272
+ restart: () => {
3273
+ if (controller.signal.aborted)
3274
+ return Promise.reject(new Error('This machine is signing out. Open Companion and sign in again.'));
3275
+ if (restarting)
3276
+ return restarting;
3277
+ requested = true;
3278
+ let timeout;
3279
+ restarting = new Promise((resolve, reject) => {
3280
+ resolveRestart = resolve;
3281
+ rejectRestart = reject;
3282
+ timeout = setTimeout(() => reject(new Error('The worker did not reconnect. Open Companion on this machine, then try again.')), 20_000);
3283
+ }).finally(() => {
3284
+ clearTimeout(timeout);
3285
+ requested = false;
3286
+ restarting = null;
3287
+ resolveRestart = null;
3288
+ rejectRestart = null;
3289
+ });
3290
+ return restarting;
3291
+ },
3292
+ stop: async () => {
3293
+ controller.abort();
3294
+ rejectRestart?.(new Error('The machine disconnected before the worker restarted.'));
3295
+ await running;
3296
+ },
3297
+ };
3270
3298
  }
package/dist/presence.js CHANGED
@@ -9,6 +9,8 @@ import { buildPresenceHeartbeatPayload } from './presence-heartbeat.js';
9
9
  import { createListenerState, orchestratorTick, reapDeadWorkers, recoverStrandedWorkers, takeRunMessages, liveAgents, } from './orchestrator.js';
10
10
  /* 18c Slice 8 — the on-disk half of the crash recovery beside it. */
11
11
  import { sweepStrandedCodexHomes } from './codex-home.js';
12
+ import { claimDaemonLock, releaseDaemonLock } from './daemon-lock.js';
13
+ import { startPanel } from './panel3/run.js';
12
14
  let presence = null;
13
15
  /** In-flight guard: startPresence yields to the event loop (network setSession)
14
16
  * before `presence` is assigned, so a plain `if (presence)` check lets two
@@ -246,21 +248,57 @@ async function cleanupSupersededRows(p) {
246
248
  }
247
249
  }
248
250
  async function pollCommands(p) {
251
+ if (p.pollingCommands)
252
+ return;
253
+ p.pollingCommands = true;
249
254
  try {
250
255
  const { data, error } = await p.client
251
256
  .from('cliv2_commands')
252
257
  .update({ status: 'ack', acked_at: new Date().toISOString() })
253
258
  .eq('machine_id', p.identity.id)
254
259
  .eq('status', 'pending')
260
+ .eq('command', 'ping')
255
261
  .select('id, command');
256
262
  if (error)
257
263
  throw error;
258
264
  for (const cmd of data ?? [])
259
265
  console.log(`Acked ${cmd.command} (${cmd.id})`);
266
+ const { data: restarts, error: restartError } = await p.client
267
+ .from('cliv2_commands')
268
+ .update({ status: 'processing' })
269
+ .eq('machine_id', p.identity.id)
270
+ .eq('status', 'pending')
271
+ .eq('command', 'restart_worker')
272
+ .select('id, created_at');
273
+ if (restartError)
274
+ throw restartError;
275
+ for (const command of restarts ?? []) {
276
+ let result = 'Worker restarted';
277
+ let status = 'ack';
278
+ try {
279
+ if (Date.now() - Date.parse(command.created_at) > 30_000) {
280
+ throw new Error('This restart request expired. Try again while the machine is online.');
281
+ }
282
+ if (!p.panel)
283
+ throw new Error('The worker has not started. Open Companion on this machine and reconnect.');
284
+ await p.panel.restart();
285
+ }
286
+ catch (error) {
287
+ status = 'failed';
288
+ result = error instanceof Error ? error.message : String(error);
289
+ }
290
+ const { error: saved } = await p.client.from('cliv2_commands')
291
+ .update({ status, result, acked_at: new Date().toISOString() }).eq('id', command.id);
292
+ if (saved)
293
+ throw saved;
294
+ }
260
295
  }
261
296
  catch (err) {
262
297
  console.warn(`command poll failed, will retry: ${err.message}`);
263
298
  }
299
+ finally {
300
+ p.pollingCommands = false;
301
+ }
264
302
  }
265
303
  /**
266
304
  * One turn of the orchestrator listener (feature 16, Slice 3). All the logic is
@@ -298,12 +336,17 @@ async function pollOrchestrator(p) {
298
336
  * the companion guard for that; the terminal daemon lets it surface. No-op if
299
337
  * already running. */
300
338
  export async function startPresence() {
339
+ if (stopping)
340
+ await stopping;
301
341
  if (presence) {
302
342
  return { machineName: presence.identity.name, agents: presence.agents };
303
343
  }
304
344
  if (starting)
305
345
  return starting;
306
346
  starting = (async () => {
347
+ const lock = claimDaemonLock();
348
+ if (lock.held)
349
+ throw new Error(`CTRL+SPC is already running on this computer (pid ${lock.pid}).`);
307
350
  const identity = getMachineIdentity();
308
351
  const agents = detectAgents();
309
352
  const client = await getClient();
@@ -337,6 +380,7 @@ export async function startPresence() {
337
380
  throw new Error('Signed-in user could not be resolved. Sign in again.');
338
381
  const p = {
339
382
  client,
383
+ panel: null,
340
384
  userId,
341
385
  identity,
342
386
  agents,
@@ -482,11 +526,19 @@ export async function startPresence() {
482
526
  catch (err) {
483
527
  console.warn(`Agent tools server did not start: ${err.message}`);
484
528
  }
529
+ // Both cs open and cs start answer cards through this one lifecycle.
530
+ // Read the owner's client on every use, including after refresh or logout.
531
+ p.panel = startPanel(() => p.client);
485
532
  return { machineName: identity.name, agents };
486
533
  })();
487
534
  try {
488
535
  return await starting;
489
536
  }
537
+ catch (error) {
538
+ await stopPresence();
539
+ releaseDaemonLock();
540
+ throw error;
541
+ }
490
542
  finally {
491
543
  starting = null;
492
544
  }
@@ -535,6 +587,7 @@ export async function stopPresence({ unregister = false } = {}) {
535
587
  call `stopPresence()` and kill the presence belonging to a DIFFERENT
536
588
  session, one that is signed in and healthy. */
537
589
  p.authSubscription.unsubscribe();
590
+ await p.panel?.stop();
538
591
  // On logout (unregister), scrub the ctrl-spc entry from each detected agent's
539
592
  // config so a logged-out machine leaves no dead server that would read "failed
540
593
  // to connect" on the next agent run. Fire-and-forget best-effort — never blocks
@@ -561,5 +614,6 @@ export async function stopPresence({ unregister = false } = {}) {
561
614
  }
562
615
  finally {
563
616
  stopping = null;
617
+ releaseDaemonLock();
564
618
  }
565
619
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ctrl-spc/cs",
3
- "version": "0.7.12",
3
+ "version": "0.7.13",
4
4
  "description": "CTRL+SPC — minimal, reliable per-machine agent presence. Sign-in, auto-start, agent detection, heartbeat presence, and ping acknowledgement.",
5
5
  "engines": {
6
6
  "node": ">=22"