@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 +26 -0
- package/dist/daemon.js +6 -122
- package/dist/folders.js +97 -9
- package/dist/panel3/checkout.js +9 -32
- package/dist/panel3/codex-models.js +96 -0
- package/dist/panel3/coordinator.js +15 -0
- package/dist/panel3/presence.js +7 -0
- package/dist/panel3/prompt.js +30 -8
- package/dist/panel3/run.js +236 -298
- package/dist/panel3/spawn.js +24 -15
- package/dist/panel3/tools.js +115 -32
- package/dist/presence.js +54 -0
- package/dist/workflow-tool-mentions.js +1 -0
- package/dist/workflows.js +7 -6
- package/package.json +2 -2
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
|
-
*
|
|
26
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
-
function pickerCommand() {
|
|
4
|
-
if (
|
|
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 (
|
|
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
|
-
|
|
19
|
-
'
|
|
20
|
-
|
|
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 {
|
package/dist/panel3/checkout.js
CHANGED
|
@@ -407,35 +407,10 @@ settings = {}) {
|
|
|
407
407
|
+ `${branch}. Check that ${codebase.name} is a git repository on ${machineName}.`);
|
|
408
408
|
}
|
|
409
409
|
}
|
|
410
|
-
/**
|
|
411
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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
|
+
}
|
package/dist/panel3/presence.js
CHANGED
|
@@ -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
|
*
|
package/dist/panel3/prompt.js
CHANGED
|
@@ -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
|
-
'
|
|
337
|
-
'
|
|
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
|
|
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
|
-
'
|
|
420
|
-
'
|
|
421
|
-
'
|
|
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
|
|
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
|
-
|
|
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
|
}
|