@ctrl-spc/cs 0.7.11 → 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/codebases.js CHANGED
@@ -101,6 +101,32 @@ export async function removeCodebase(client, codebaseId) {
101
101
  if (error)
102
102
  throw new Error(error.message);
103
103
  }
104
+ /** Saved work-item scope is shared by the reader and the dispatch guard. */
105
+ export async function listWorkItemCodebaseTargets(client, workItemId) {
106
+ const { data, error } = await client.from('cliv2_task_codebase_targets')
107
+ .select('git_remote_url').eq('task_id', workItemId).order('git_remote_url');
108
+ if (error)
109
+ throw new Error(`Could not read the saved work-item codebase targets: ${error.message}`);
110
+ return (data ?? []).map(row => row.git_remote_url);
111
+ }
112
+ /** The existing database transaction owns membership and final-target guards. */
113
+ export async function editWorkItemCodebaseTarget(client, workItemId, projectId, codebaseId, add) {
114
+ const codebases = await client.from('cliv2_codebases').select('git_remote_url')
115
+ .eq('id', codebaseId).eq('project_id', projectId);
116
+ if (codebases.error)
117
+ throw new Error(`Could not read the project codebase: ${codebases.error.message}`);
118
+ if (codebases.data?.length !== 1)
119
+ throw new Error("Could not read a codebase belonging to this work item's project.");
120
+ const { data, error } = await client.rpc('cliv2_edit_task_codebase_target', {
121
+ p_task_id: workItemId, p_git_remote_url: codebases.data[0].git_remote_url, p_add: add,
122
+ });
123
+ if (error)
124
+ throw new Error(`Could not edit the saved work-item codebase targets: ${error.message}`);
125
+ if (!Array.isArray(data) || !data.every(target => typeof target === 'string')) {
126
+ throw new Error('The saved work-item codebase targets could not be read back after editing.');
127
+ }
128
+ return data;
129
+ }
104
130
  /**
105
131
  * Record that THIS machine has a codebase's folder checked out — Phase 2
106
132
  * per-machine availability. Upserts one path-free row into the owner-scoped
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 {
@@ -407,35 +407,10 @@ settings = {}) {
407
407
  + `${branch}. Check that ${codebase.name} is a git repository on ${machineName}.`);
408
408
  }
409
409
  }
410
- /**
411
- * ═══ THE CARD IS OVER: COMMIT WHAT IS THERE, THEN TAKE THE COPY AWAY. ═══
412
- *
413
- * The commit comes first and is unconditional on there being something to
414
- * commit, so removing the copy cannot lose work. The BRANCH is never touched:
415
- * work that never landed survives until a person deals with it.
416
- *
417
- * It takes the folder alone, and that is deliberate: a worktree knows its own
418
- * repository, so the sweep needs neither the located checkout nor the codebase
419
- * row to clean one up.
420
- */
421
- /**
422
- * ═══ EVERYTHING IN A CARD'S COPY, ON ITS BRANCH. ═══
423
- *
424
- * `settleCardWorktree`'s own first three lines, extracted rather than rewritten,
425
- * because the landing needs the commit BEFORE the merge and the sweep needs it
426
- * before the removal. Committing in two places with two messages is how one card
427
- * comes to have two ideas of what its work is.
428
- *
429
- * ═══ ITS FAILURE IS COMPOSED, WHICH THE SWEEP NEVER NEEDED. ═══ The sweep
430
- * writes to stderr, so git's own text was harmless there. On the landing path a
431
- * failure reaches a person through an agent, and `execFileSync`'s message begins
432
- * `Command failed: git -C <absolute path>`. So this wraps in `worktreeForCard`'s
433
- * shape: what was composed here passes through, anything else is replaced.
434
- */
410
+ /** Commit only for an explicitly selected landing action. Cleanup never commits.
411
+ * Failures are path-free because the landing result reaches the hosted panel. */
435
412
  export function commitCardWork(folder,
436
- /* WHAT THE COPY IS, IN WORDS AND NEVER AS A PATH. The sweep has only the
437
- folder and says so; the landing has the codebase and the branch and names
438
- both, which is what a person reading the failure needs. */
413
+ // Name the codebase and branch in failures, without exposing a local path.
439
414
  describe) {
440
415
  try {
441
416
  git(folder, ['add', '-A']);
@@ -491,11 +466,13 @@ export function mergeIntoBase(source, branch, base, codebaseName) {
491
466
  }
492
467
  }
493
468
  export function settleCardWorktree(folder) {
494
- commitCardWork(folder, 'this card\'s working copy');
495
- /* FORCE, AFTER THE COMMIT. Everything tracked is now committed on the branch,
496
- so what `--force` overrides is only ignored files: an installed
497
- `node_modules`, a build output, which the next copy makes again. */
469
+ // Completion is not permission to commit. Preserve unfinished Git work,
470
+ // including staged and untracked files, for the person to review or resume.
471
+ if (git(folder, ['status', '--porcelain']).length > 0)
472
+ return false;
473
+ // Only ignored build/dependency files can remain in a clean copy.
498
474
  removeWorktree(folder);
475
+ return true;
499
476
  }
500
477
  /**
501
478
  * Every card copy this machine currently holds, as `<codebase id>/<folder>`
@@ -0,0 +1,96 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { z } from 'zod';
3
+ import { agentPath } from '../agents.js';
4
+ import { killTree, windowsSafeSpawn } from '../win-shell.js';
5
+ const modelPage = z.object({
6
+ data: z.array(z.object({
7
+ model: z.string().min(1),
8
+ displayName: z.string(),
9
+ description: z.string(),
10
+ defaultReasoningEffort: z.string(),
11
+ supportedReasoningEfforts: z.array(z.object({ reasoningEffort: z.string(), description: z.string() })),
12
+ isDefault: z.boolean(),
13
+ })),
14
+ nextCursor: z.string().nullable(),
15
+ });
16
+ /** Read the installed harness's catalog under its own account/config. No turn,
17
+ * session, product catalog, or generation is created. Keep provider IDs intact. */
18
+ export async function listCodexModels() {
19
+ const bin = agentPath('codex');
20
+ if (!bin)
21
+ throw new Error('Codex is not installed on this machine.');
22
+ const { args, shell } = windowsSafeSpawn(bin, ['app-server']);
23
+ return new Promise((resolve, reject) => {
24
+ const child = spawn(bin, args, { shell, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] });
25
+ const models = [];
26
+ const cursors = new Set();
27
+ let buffer = '';
28
+ let received = 0;
29
+ let settled = false;
30
+ const finish = (error) => {
31
+ if (settled)
32
+ return;
33
+ settled = true;
34
+ clearTimeout(timer);
35
+ killTree(child);
36
+ if (error)
37
+ reject(error);
38
+ else
39
+ resolve(models);
40
+ };
41
+ const timer = setTimeout(() => finish(new Error('Codex model discovery timed out.')), 15_000);
42
+ const send = (message) => child.stdin.write(`${JSON.stringify(message)}\n`);
43
+ child.on('error', (error) => finish(error));
44
+ child.stdin.on('error', (error) => finish(error));
45
+ child.on('close', () => finish(new Error('Codex exited before returning its available models.')));
46
+ // Diagnostics can contain local configuration; never return them as catalog data.
47
+ child.stderr.resume();
48
+ child.stdout.setEncoding('utf8').on('data', (chunk) => {
49
+ if (settled)
50
+ return;
51
+ received += chunk.length;
52
+ if (received > 1_000_000)
53
+ return finish(new Error('Codex model discovery response was too large.'));
54
+ buffer += chunk;
55
+ let newline;
56
+ while (!settled && (newline = buffer.indexOf('\n')) >= 0) {
57
+ const line = buffer.slice(0, newline);
58
+ buffer = buffer.slice(newline + 1);
59
+ if (!line.trim())
60
+ continue;
61
+ try {
62
+ const response = JSON.parse(line);
63
+ if (response.id !== 1 && response.id !== 2)
64
+ continue;
65
+ if (response.error)
66
+ throw new Error('Codex rejected model discovery. Use the installed default or an explicit user choice.');
67
+ if (response.id === 1) {
68
+ send({ method: 'initialized' });
69
+ send({ id: 2, method: 'model/list', params: { limit: 100, includeHidden: false } });
70
+ }
71
+ else {
72
+ const page = modelPage.parse(response.result);
73
+ models.push(...page.data);
74
+ if (page.nextCursor !== null) {
75
+ if (cursors.has(page.nextCursor))
76
+ throw new Error('Codex model discovery repeated a page.');
77
+ cursors.add(page.nextCursor);
78
+ send({ id: 2, method: 'model/list', params: { limit: 100, includeHidden: false, cursor: page.nextCursor } });
79
+ }
80
+ else if (models.length === 0) {
81
+ finish(new Error('Codex returned no available models.'));
82
+ }
83
+ else
84
+ finish();
85
+ }
86
+ }
87
+ catch (error) {
88
+ finish(error instanceof SyntaxError || error instanceof z.ZodError
89
+ ? new Error('Codex returned an invalid available-model list. Use the installed default or an explicit user choice.')
90
+ : error instanceof Error ? error : new Error('Codex model discovery failed.'));
91
+ }
92
+ }
93
+ });
94
+ send({ id: 1, method: 'initialize', params: { clientInfo: { name: 'ctrl_spc_models', version: '1' } } });
95
+ });
96
+ }
@@ -1,4 +1,6 @@
1
1
  import { returned } from './client.js';
2
+ import { agentPath } from '../agents.js';
3
+ import { harness } from './spawn.js';
2
4
  /**
3
5
  * Read the designation. `cliv2_orchestrator_preference` carries `unique
4
6
  * (user_id)`, so RLS alone (`auth.uid() = user_id`) can never return more than
@@ -14,5 +16,18 @@ import { returned } from './client.js';
14
16
  export async function designatedCoordinator(client) {
15
17
  const rows = await returned(client.from('cliv2_orchestrator_preference').select('machine_id, agent'), 'read', 'which machine and harness are designated to coordinate');
16
18
  const row = rows[0];
19
+ if (row && (typeof row.machine_id !== 'string' || !['claude', 'codex'].includes(row.agent))) {
20
+ throw new Error('The saved primary agent is invalid; select Claude Code or Codex again.');
21
+ }
17
22
  return row ? { machineId: row.machine_id, agent: row.agent } : null;
18
23
  }
24
+ /** New work follows the shared choice. Existing runs keep their recorded harness. */
25
+ export async function selectedHarness(client, machineId) {
26
+ const designation = await designatedCoordinator(client);
27
+ const selected = designation?.machineId === machineId
28
+ ? designation.agent
29
+ : harness();
30
+ if (!agentPath(selected))
31
+ throw new Error(`${selected} is selected but is not installed on this machine`);
32
+ return selected;
33
+ }
@@ -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,14 +327,17 @@ 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 ? [] : [
333
335
  '',
334
336
  'THE CODEBASES THIS CARD TOUCHES',
335
337
  ...codebases,
336
- 'For a dispatched piece, its one codebase is named separately below. The other selected',
337
- 'codebases are separate work, not missing folders, so do not look for or report on them.',
338
+ 'Each worker stays in its separately assigned codebase. The conversation owner remains',
339
+ 'responsible for the whole request across every required codebase, including work delegated',
340
+ 'to other workers. A card attachment or launch brief is not a saved work-item target read.',
338
341
  ]),
339
342
  ...personAttachmentLines,
340
343
  ];
@@ -371,7 +374,9 @@ const LANDING_LINES = {
371
374
  ],
372
375
  main: [
373
376
  'Finished work in this codebase lands on the main branch, and the person is asked first.',
374
- 'When the card\'s work is done, call `offer_ending` with one sentence saying what was done.',
377
+ 'When the whole assignment is done, first write_report with work_complete=true and its verified',
378
+ 'results, then call `offer_ending` with one sentence saying what was done. Never offer an',
379
+ 'ending while you are planning, waiting for workers, or still owe work in another codebase.',
375
380
  'The product writes the question and both answers, and it performs the merge itself if they',
376
381
  'choose to put the work back. Landing is the product\'s job and never yours: never merge and',
377
382
  'never push.',
@@ -416,9 +421,10 @@ const projectCodebases = (codebases) => {
416
421
  : [
417
422
  ...codebases.map((codebase) => (`${codebase.name} — id ${codebase.id} — ${codebase.identity} — `
418
423
  + (codebase.located ? 'located on this machine' : 'not located on this machine'))),
419
- 'Choose the codebase that contains the work and include its id when you send somebody.',
420
- 'If several codebases are needed, send one person per codebase. If the request does not make',
421
- 'the choice clear, call `ask_question` once with `answer_mode` set to `multi_select`, put the',
424
+ 'For an attached work item, read its saved targets; this project list is not its selection.',
425
+ 'Launch one owner for the whole assignment. For several targets, that owner sends a scoped',
426
+ 'worker to each codebase. If the saved record and request do not make the choice clear,',
427
+ 'call `ask_question` once with `answer_mode` set to `multi_select`, put the',
422
428
  'plausible codebase names in `options`, and stop. Do not write a question as your reply.',
423
429
  ]),
424
430
  ];
@@ -479,6 +485,10 @@ export function levelOnePrompt(cardTitle, messages, produced, attachments = [],
479
485
  'Launch one owner for the conversation. Give it the complete responsibility and boundary. If',
480
486
  'the request belongs to a registered codebase, name it. If it is record-only work, launch it',
481
487
  'without a codebase. The owner talks to the person and may send workers of its own.',
488
+ 'For work on a saved Work Item, read get_work_item for its current saved codebase targets.',
489
+ 'Use those targets, not the project-wide choices or a remembered selection. Do not ask the',
490
+ 'person to repeat a saved selection. If several targets are required, give the ONE owner the',
491
+ 'whole assignment and every required target; it can dispatch a worker in each codebase.',
482
492
  'Your only question is a destination or codebase you truly cannot choose. NEVER ask the person',
483
493
  'to make a work or product decision. When one registered codebase clearly fits, launch its owner',
484
494
  'even if the work itself needs the person to choose between options; the owner asks that question.',
@@ -559,6 +569,9 @@ export const OWNER_COMPLETION_RULES = [
559
569
  'without declaring completion, the product starts you again to carry on.',
560
570
  'Only when the latest user assignment is fulfilled, call write_report with work_complete=true',
561
571
  'and a report explaining what fulfilled it. Check all required steps and verification first.',
572
+ 'Only offer_ending after every requested codebase has its required work verified. Owning',
573
+ 'one working copy does not narrow a multi-codebase request to that copy. Dispatch the remaining',
574
+ 'codebase work and read its results before offering completion.',
562
575
  'Then give the final answer and exit. Do not declare completion for an interim status answer.',
563
576
  'A failed or skipped required step is unfinished work, even if all workers have returned.',
564
577
  'Resolve it or ask for the real required intervention; never convert it into a completion caveat.',
@@ -602,6 +615,9 @@ export function workBrief(level, responsibility, boundary, workItemId, attachmen
602
615
  'When asked to create a workflow, use create_workflow to save it; do not execute the workflow.',
603
616
  'Ask clarifying questions with ask_question only when the missing answer changes its behavior.',
604
617
  'Write the requested tool mentions and per-use permissions in the stage bodies using the tool schema instructions.',
618
+ 'Plain-prose requests must stay plain: do not add unrequested tool permissions or approval templates.',
619
+ 'For edits, read get_workflow first, reword retained stages with reword_stage, and keep their numbered references in edit_workflow. Do not replace retained stages or bypass a protected-body refusal.',
620
+ 'Only you can call get_workflow. If a child is assigned a workflow review, read it yourself first and put the complete relevant workflow content in its brief; do not assign it an unavailable tool call. Authoring and review do not authorize executing the workflow or its stages.',
605
621
  'The saved workflow appears as a reviewable card in this conversation. Do not substitute a plan artifact or a prose-only reply.',
606
622
  '',
607
623
  'TRACEABILITY BEFORE A CODEBASE FILE CHANGES',
@@ -661,7 +677,10 @@ export function workBrief(level, responsibility, boundary, workItemId, attachmen
661
677
  'before you send anybody is a minute they watch a line with no answer coming.',
662
678
  '',
663
679
  'IF YOU SPLIT IT',
664
- 'Everybody you send works in the SAME copy of the codebase as everybody else, at the same',
680
+ 'Everybody you send to the SAME codebase shares its copy; different codebases have separate',
681
+ 'copies. Read the Work Item\'s current saved targets before file work or dispatch, and use',
682
+ 'only those targets. Preserve the person\'s no-commit, no-merge and no-push instructions in',
683
+ 'every worker boundary and at completion. People in the same copy work at the same',
665
684
  'time. Nothing keeps them out of each other\'s way, so anybody CHANGING files needs a piece',
666
685
  'that touches nobody else\'s files, or has to be sent on their own.',
667
686
  /* ═══ AND A WORKFLOW OVERRIDES THAT DEFAULT, INSIDE THE BLOCK THAT SETS
@@ -1170,9 +1189,12 @@ export function modelChoiceRules(harness) {
1170
1189
  return [
1171
1190
  'MODEL AND EFFORT FOR EACH DISPATCH',
1172
1191
  `You run under ${harness}. Your dispatched children use the same harness.`,
1192
+ 'Claude and Codex name harnesses, not model identifiers. A request to use Codex gpt-5.5 means model gpt-5.5 under Codex; do not concatenate a harness label into the model argument. A harness-only request does not specify a model. If you do not know a supported model for the task, omit model to use the installed default rather than inventing an identifier.',
1173
1193
  'The dispatch tool accepts optional model and effort strings. Honor the user\'s exact stated model and effort for the work they apply to, including downstream workers. Copy each requested value byte-for-byte into the dispatch argument: do not expand aliases, normalize spelling or case, or replace it with a canonical provider identifier. Carry those wishes and their scope verbatim in every applicable responsibility and boundary.',
1174
1194
  'When the user requests separate workers or assigns choices to separate parts, call dispatch for those parts. Doing their work yourself does not satisfy that request. If product tools are deferred, discover dispatch before starting; use the product ask_question and write_report tools for questions and completion.',
1175
- 'When the user has not specified a value, choose for this child\'s task using your knowledge of this harness. Pass your choice in the tool arguments; a sentence alone does not select it. Different workers may use different choices.',
1195
+ harness === 'codex'
1196
+ ? 'When the user has not specified a model, call list_codex_models before choosing for this child. Choose a model ID from that live result and an effort it supports, according to the task. If discovery fails, omit autonomous model and effort choices and use installed defaults; do not guess from memory. The list does not override explicit user IDs or aliases. Different workers may use different choices. Pass choices in dispatch arguments; prose alone does not select them.'
1197
+ : 'When the user has not specified a value, choose for this child\'s task using supported Claude aliases such as sonnet or opus. Do not invent version-suffixed aliases such as sonnet-5 or haiku-4.5. If uncertain, omit model to use the installed default. Pass your choice in the tool arguments; a sentence alone does not select it. Different workers may use different choices.',
1176
1198
  'Omitting a value chooses the harness default (or the machine-local model default), never the parent\'s model or effort. No product model catalog exists. Pass provider values unchanged; do not silently substitute after a rejection.',
1177
1199
  ].join('\n');
1178
1200
  }