conductor-remote 1.107.0 → 1.108.0

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.
@@ -0,0 +1,230 @@
1
+ import { execFile, execFileSync } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ const execFileAsync = promisify(execFile);
4
+ /**
5
+ * Decide from argv only. Never ask `ps` for the environment: a Conductor child or
6
+ * relay process can carry bearer tokens there.
7
+ */
8
+ export function isUiCapableRelayArgs(args) {
9
+ const normalized = args.replace(/\s+/g, ' ').trim();
10
+ if (!/(^|\/)node(?:\s|$)/.test(normalized))
11
+ return false;
12
+ if (/\bconductor-remote(?:\/bin\/cli\.js)?\s+(?:mcp|service|config|logs|nosleep)(?:\s|$)/.test(normalized)) {
13
+ return false;
14
+ }
15
+ // `yarn start` and the documented checkout command can leave the script path
16
+ // relative in `ps` (for example `node bin/cli.js`). Treat a whitespace or path
17
+ // separator as the same token boundary. Utility subcommands are excluded first
18
+ // so a relative MCP proxy can never be mistaken for a UI-driving relay.
19
+ if (/(?:^|\s|\/)(?:conductor-remote\/)?bin\/cli\.js\s+(?:mcp|service|config|logs|nosleep)(?:\s|$)/.test(normalized)) {
20
+ return false;
21
+ }
22
+ if (/(?:^|\s|\/)(?:src\/server\.ts|dist-node\/src\/server\.js)(?:\s|$)/.test(normalized))
23
+ return true;
24
+ if (/(?:^|\s|\/)(?:conductor-remote\/)?bin\/cli\.js(?:\s+(?:start))?(?:\s|$)/.test(normalized))
25
+ return true;
26
+ return /\bconductor-remote(?:\s+start)?(?:\s|$)/.test(normalized);
27
+ }
28
+ /** Parse macOS `ps -axo pid=,lstart=,args=` without depending on locale words. */
29
+ export function parseUiCapableRelayProcesses(output, ownPid = process.pid) {
30
+ const found = [];
31
+ for (const line of output.split('\n')) {
32
+ // macOS lstart is five fields: weekday month day hh:mm:ss year. Capture by
33
+ // fields rather than a byte width so single-digit days and locale spacing work.
34
+ const match = /^\s*(\d+)\s+(\S+\s+\S+\s+\d+\s+\d\d:\d\d:\d\d\s+\d{4})\s+(.+)$/.exec(line);
35
+ if (!match)
36
+ continue;
37
+ const pid = Number(match[1]);
38
+ if (!Number.isSafeInteger(pid) || pid <= 0 || pid === ownPid)
39
+ continue;
40
+ const args = match[3].trim();
41
+ if (!isUiCapableRelayArgs(args))
42
+ continue;
43
+ found.push({ pid, processStartIdentity: match[2].replace(/\s+/g, ' '), args });
44
+ }
45
+ return found.sort((a, b) => a.pid - b.pid);
46
+ }
47
+ export async function listUiCapableRelayProcesses(ownPid = process.pid) {
48
+ const { stdout } = await execFileAsync('/bin/ps', ['-axo', 'pid=,lstart=,args='], {
49
+ encoding: 'utf8',
50
+ timeout: 6_000,
51
+ maxBuffer: 4 * 1024 * 1024
52
+ });
53
+ return parseUiCapableRelayProcesses(stdout, ownPid);
54
+ }
55
+ function normalizeStartIdentity(value) {
56
+ return value.replace(/\s+/g, ' ').trim();
57
+ }
58
+ const PROCESS_TABLE_ARGS = ['-axo', 'pid=,pgid=,lstart='];
59
+ function parseProcessTable(output) {
60
+ const entries = [];
61
+ for (const line of output.split('\n')) {
62
+ const match = /^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/.exec(line);
63
+ if (!match)
64
+ continue;
65
+ const pid = Number(match[1]);
66
+ const processGroup = Number(match[2]);
67
+ if (!Number.isSafeInteger(pid) || pid <= 0 || !Number.isSafeInteger(processGroup) || processGroup <= 0) {
68
+ continue;
69
+ }
70
+ entries.push({ pid, processGroup, processStartedAt: normalizeStartIdentity(match[3]) });
71
+ }
72
+ return entries;
73
+ }
74
+ function readProcessTableSync() {
75
+ try {
76
+ const output = execFileSync('/bin/ps', [...PROCESS_TABLE_ARGS], {
77
+ encoding: 'utf8',
78
+ timeout: 2_000,
79
+ maxBuffer: 4 * 1024 * 1024
80
+ });
81
+ return parseProcessTable(output);
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ }
87
+ async function readProcessTable() {
88
+ try {
89
+ const { stdout } = await execFileAsync('/bin/ps', [...PROCESS_TABLE_ARGS], {
90
+ encoding: 'utf8',
91
+ timeout: 2_000,
92
+ maxBuffer: 4 * 1024 * 1024
93
+ });
94
+ return parseProcessTable(stdout);
95
+ }
96
+ catch {
97
+ return null;
98
+ }
99
+ }
100
+ function exactLeader(entries, identity) {
101
+ const expectedStart = normalizeStartIdentity(identity.processStartedAt);
102
+ return entries.find(entry => entry.pid === identity.pid && normalizeStartIdentity(entry.processStartedAt) === expectedStart);
103
+ }
104
+ function validProcessGroup(processGroup) {
105
+ return Number.isSafeInteger(processGroup) && processGroup > 1;
106
+ }
107
+ function groupAlive(entries, processGroup) {
108
+ return entries.some(entry => entry.processGroup === processGroup);
109
+ }
110
+ function sameProcess(left, right) {
111
+ return left.pid === right.pid && left.processStartedAt === right.processStartedAt;
112
+ }
113
+ /**
114
+ * Exact PID + process-start check, extended for a recorded detached wrapper.
115
+ * A dead/reused leader is still conservatively alive while any member retains
116
+ * its process group. Failure to read the process table also fails closed.
117
+ */
118
+ export function processIdentityAlive(identity) {
119
+ const entries = readProcessTableSync();
120
+ if (!entries)
121
+ return true;
122
+ if (exactLeader(entries, identity))
123
+ return true;
124
+ if (identity.processGroup === undefined)
125
+ return false;
126
+ if (!validProcessGroup(identity.processGroup))
127
+ return true;
128
+ return groupAlive(entries, identity.processGroup);
129
+ }
130
+ function duration(value, fallback, minimum) {
131
+ return value === undefined || !Number.isFinite(value) ? fallback : Math.max(minimum, Math.floor(value));
132
+ }
133
+ async function waitForGroupDeath(processGroup, waitMs, pollIntervalMs) {
134
+ const deadline = Date.now() + waitMs;
135
+ for (;;) {
136
+ const entries = await readProcessTable();
137
+ if (!entries)
138
+ return false;
139
+ if (!groupAlive(entries, processGroup))
140
+ return true;
141
+ const remaining = deadline - Date.now();
142
+ if (remaining <= 0)
143
+ return false;
144
+ await new Promise(resolve => setTimeout(resolve, Math.min(pollIntervalMs, remaining)));
145
+ }
146
+ }
147
+ function signalGroup(processGroup, signal) {
148
+ try {
149
+ process.kill(-processGroup, signal);
150
+ return true;
151
+ }
152
+ catch (error) {
153
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ESRCH')
154
+ return true;
155
+ return false;
156
+ }
157
+ }
158
+ /**
159
+ * Terminate a detached wrapper's process group without risking a reused PGID.
160
+ *
161
+ * The recorded leader must still have the exact PID/start identity, must still
162
+ * lead the recorded group, and must not be this relay. Only then is SIGTERM sent.
163
+ * SIGKILL follows when descendants survive the grace period, unless a reused
164
+ * leader is observed. `true` means a later process-table snapshot proved that no
165
+ * member of the recorded group remains; every inspection failure returns false.
166
+ */
167
+ export async function terminateRecordedProcessGroup(identity, options = {}) {
168
+ if (!Number.isSafeInteger(identity.pid) ||
169
+ identity.pid <= 1 ||
170
+ identity.pid === process.pid ||
171
+ !validProcessGroup(identity.processGroup) ||
172
+ identity.processGroup !== identity.pid ||
173
+ !normalizeStartIdentity(identity.processStartedAt)) {
174
+ return false;
175
+ }
176
+ const termGraceMs = duration(options.termGraceMs, 500, 0);
177
+ const killWaitMs = duration(options.killWaitMs, 2_000, 0);
178
+ const pollIntervalMs = duration(options.pollIntervalMs, 25, 1);
179
+ const initial = await readProcessTable();
180
+ if (!initial)
181
+ return false;
182
+ const leader = exactLeader(initial, identity);
183
+ if (!leader)
184
+ return !groupAlive(initial, identity.processGroup);
185
+ if (leader.processGroup !== identity.processGroup)
186
+ return false;
187
+ const initialMembers = initial.filter(entry => entry.processGroup === identity.processGroup);
188
+ if (!signalGroup(identity.processGroup, 'SIGTERM'))
189
+ return false;
190
+ if (await waitForGroupDeath(identity.processGroup, termGraceMs, pollIntervalMs))
191
+ return true;
192
+ const beforeKill = await readProcessTable();
193
+ if (!beforeKill)
194
+ return false;
195
+ if (!groupAlive(beforeKill, identity.processGroup))
196
+ return true;
197
+ const currentLeader = beforeKill.find(entry => entry.pid === identity.pid);
198
+ if (currentLeader &&
199
+ (normalizeStartIdentity(currentLeader.processStartedAt) !== normalizeStartIdentity(identity.processStartedAt) ||
200
+ currentLeader.processGroup !== identity.processGroup)) {
201
+ return false;
202
+ }
203
+ const survivingMembers = beforeKill.filter(entry => entry.processGroup === identity.processGroup);
204
+ if (!survivingMembers.some(current => initialMembers.some(initialMember => sameProcess(current, initialMember)))) {
205
+ return false;
206
+ }
207
+ if (!signalGroup(identity.processGroup, 'SIGKILL'))
208
+ return false;
209
+ return waitForGroupDeath(identity.processGroup, killWaitMs, pollIntervalMs);
210
+ }
211
+ export function currentProcessStartIdentity(pid = process.pid) {
212
+ const output = execFileSync('/bin/ps', ['-p', String(pid), '-o', 'lstart='], {
213
+ encoding: 'utf8',
214
+ timeout: 2_000,
215
+ maxBuffer: 64 * 1024
216
+ });
217
+ const identity = normalizeStartIdentity(output);
218
+ if (!identity)
219
+ throw new Error(`could not read process start identity for PID ${pid}`);
220
+ return identity;
221
+ }
222
+ /** A live UI-capable process must have a matching registration at this protocol. */
223
+ export function incompatibleRelayProcesses(processes, registrations, protocolVersion) {
224
+ return processes.filter(candidate => {
225
+ const registration = registrations.find(entry => entry.canDriveUi &&
226
+ entry.pid === candidate.pid &&
227
+ normalizeStartIdentity(entry.processStartedAt) === normalizeStartIdentity(candidate.processStartIdentity));
228
+ return !registration || registration.protocolVersion !== protocolVersion;
229
+ });
230
+ }
@@ -9,13 +9,35 @@
9
9
  import fs from 'node:fs';
10
10
  import path from 'node:path';
11
11
  import { stateDir } from "./config.js";
12
- import { agentTypeCanExposeEffort, agentTypeCanExposeFastMode, modelAgentType, modelCatalogIncludes, modelPickerLabel } from "./shared.js";
12
+ import { modelAgentType, modelCatalogIncludes, modelPickerLabel, newestModelSnapshot } from "./shared.js";
13
+ export { newestModelSnapshot } from "./shared.js";
13
14
  const EFFORTS = new Set(['none', 'low', 'medium', 'high', 'xhigh', 'max', 'ultracode']);
14
15
  const ROLE_NAME = /^[a-z][a-z0-9_-]{0,63}$/;
15
16
  const MAX_ROLES = 32;
16
17
  const MAX_MODEL_LENGTH = 256;
17
18
  const MAX_PREAMBLE_LENGTH = 50_000;
18
19
  const ROLE_FIELDS = new Set(['model', 'effort', 'fast', 'preamble']);
20
+ /**
21
+ * Controls verified against Conductor's provider-specific composer UI.
22
+ *
23
+ * This is intentionally versioned and deliberately contains only providers whose
24
+ * controls have been measured. A provider recognized from its model label but absent
25
+ * here can still be used with its defaults; role configuration must omit controls we
26
+ * cannot prove Conductor can apply and read back.
27
+ */
28
+ export const ROLE_CONTROL_CAPABILITIES = {
29
+ version: 1,
30
+ providers: {
31
+ claude: {
32
+ efforts: ['low', 'medium', 'high', 'xhigh', 'max', 'ultracode'],
33
+ fast: true
34
+ },
35
+ codex: {
36
+ efforts: ['none', 'low', 'medium', 'high', 'xhigh', 'max', 'ultracode'],
37
+ fast: true
38
+ }
39
+ }
40
+ };
19
41
  const batonPreamble = (role) => `You are the ${role} agent for this workspace. End your final answer with a \`## Baton\` section: Decision, Evidence, Files changed, Risks, Suggested next role.`;
20
42
  export const DEFAULT_ROLES = {
21
43
  version: 1,
@@ -89,25 +111,27 @@ function issue(code, message) {
89
111
  return { code, message, retryable: false };
90
112
  }
91
113
  function controlIssue(name, role, agentType) {
92
- if (role.effort !== undefined && !agentTypeCanExposeEffort(agentType)) {
93
- return issue('invalid_request', `Role ${name} cannot set effort because Conductor exposes no reasoning control for this provider.`);
114
+ const controls = ROLE_CONTROL_CAPABILITIES.providers[agentType];
115
+ if (role.effort !== undefined && !controls) {
116
+ return issue('invalid_request', `Role ${name} must omit effort because this provider's Conductor reasoning control is not verified.`);
94
117
  }
95
- if (role.effort === 'none' && agentType !== 'codex') {
96
- return issue('invalid_request', `Role ${name} can use None effort only with a Codex model.`);
118
+ if (role.effort !== undefined && controls && !controls.efforts.includes(role.effort)) {
119
+ return issue('invalid_request', `Role ${name} cannot use ${role.effort} effort with its configured provider.`);
97
120
  }
98
- if (role.fast !== undefined && !agentTypeCanExposeFastMode(agentType)) {
99
- return issue('invalid_request', `Role ${name} cannot set Fast mode because Conductor exposes no Fast control for this provider.`);
121
+ if (role.fast !== undefined && !controls?.fast) {
122
+ return issue('invalid_request', `Role ${name} must omit Fast mode because this provider's Conductor Fast control is not verified.`);
100
123
  }
101
124
  return null;
102
125
  }
103
126
  /** Picker validation stays separate from shape decoding so a vanished model remains editable. */
104
127
  export function roleModelIssues(config, groups) {
105
128
  const issues = [];
129
+ const snapshot = newestModelSnapshot(groups);
106
130
  for (const [name, role] of Object.entries(config.roles)) {
107
- if (!modelCatalogIncludes(role.model, groups)) {
131
+ if (!snapshot || !modelCatalogIncludes(role.model, [snapshot])) {
108
132
  issues.push({
109
133
  role: name,
110
- error: issue('model_missing', `Role ${name} needs an exact model from Conductor's current picker.`)
134
+ error: issue('model_missing', `Role ${name} needs an exact model from Conductor's newest picker snapshot; update it in the role editor.`)
111
135
  });
112
136
  continue;
113
137
  }
@@ -130,10 +154,11 @@ export function resolveRole(config, name, groups) {
130
154
  const role = config.roles[name];
131
155
  if (!role)
132
156
  return { ok: false, error: issue('role_not_found', `Unknown delegated role ${name}.`) };
133
- if (!modelCatalogIncludes(role.model, groups)) {
157
+ const snapshot = newestModelSnapshot(groups);
158
+ if (!snapshot || !modelCatalogIncludes(role.model, [snapshot])) {
134
159
  return {
135
160
  ok: false,
136
- error: issue('model_missing', `Role ${name} needs an exact model from Conductor's current picker.`)
161
+ error: issue('model_missing', `Role ${name} needs an exact model from Conductor's newest picker snapshot; update it in the role editor.`)
137
162
  };
138
163
  }
139
164
  const agentType = modelAgentType(role.model);
@@ -152,11 +177,36 @@ export function resolveRole(config, name, groups) {
152
177
  export class RoleStore {
153
178
  file;
154
179
  cache = null;
180
+ cacheStamp;
155
181
  constructor(file = path.join(stateDir(), 'roles.json')) {
156
182
  this.file = file;
157
183
  }
184
+ fileStamp() {
185
+ try {
186
+ const stat = fs.statSync(this.file);
187
+ return `${stat.mtimeMs}:${stat.size}:${stat.ino}`;
188
+ }
189
+ catch (err) {
190
+ if (err.code === 'ENOENT')
191
+ return null;
192
+ throw err;
193
+ }
194
+ }
158
195
  read() {
159
- if (this.cache)
196
+ let stamp;
197
+ try {
198
+ stamp = this.fileStamp();
199
+ }
200
+ catch (err) {
201
+ stamp = null;
202
+ this.cache = {
203
+ config: cloneConfig(DEFAULT_ROLES),
204
+ warning: `Could not inspect roles.json: ${err instanceof Error ? err.message : String(err)}`
205
+ };
206
+ this.cacheStamp = undefined;
207
+ return { ...this.cache, config: cloneConfig(this.cache.config) };
208
+ }
209
+ if (this.cache && this.cacheStamp === stamp)
160
210
  return { ...this.cache, config: cloneConfig(this.cache.config) };
161
211
  try {
162
212
  this.cache = { config: decodeRoles(JSON.parse(fs.readFileSync(this.file, 'utf8'))) };
@@ -171,6 +221,7 @@ export class RoleStore {
171
221
  };
172
222
  }
173
223
  }
224
+ this.cacheStamp = stamp;
174
225
  return { ...this.cache, config: cloneConfig(this.cache.config) };
175
226
  }
176
227
  write(raw) {
@@ -197,6 +248,7 @@ export class RoleStore {
197
248
  return { ok: false, error: `could not persist roles: ${err instanceof Error ? err.message : String(err)}` };
198
249
  }
199
250
  this.cache = { config };
251
+ this.cacheStamp = this.fileStamp();
200
252
  return { ok: true, config: cloneConfig(config) };
201
253
  }
202
254
  }
@@ -101,6 +101,19 @@ export const routes = {
101
101
  delegations: flat('GET', '/api/delegations'),
102
102
  /** Dismiss one failed job without touching its chats or session-role identity. */
103
103
  dismissDelegation: param('DELETE', '/api/delegations/:delegationId'),
104
+ /** UI-only intake for a durable Workflow; MCP deliberately exposes no start tool. */
105
+ workflows: flat('POST', '/api/workflows'),
106
+ /** Cancel without destructive Conductor UI actions; `clientId` travels in the query. */
107
+ workflow: param('DELETE', '/api/workflows/:workflowId'),
108
+ /** The only agent-facing Workflow mutation, authorized by the current phase capability. */
109
+ workflowDelegation: param('POST', '/api/workflows/:workflowId/delegations'),
110
+ /** Phone-only, idempotent recovery actions for a blocked Workflow. */
111
+ workflowRetry: param('POST', '/api/workflows/:workflowId/retry'),
112
+ workflowAdopt: param('POST', '/api/workflows/:workflowId/adopt'),
113
+ workflowReplay: param('POST', '/api/workflows/:workflowId/replay'),
114
+ workflowComplete: param('POST', '/api/workflows/:workflowId/complete'),
115
+ /** Phone-only acknowledgement that the user inspected Conductor after an ambiguous UI dispatch. */
116
+ confirmUiStable: flat('POST', '/api/ui-quarantine/confirm'),
104
117
  // ── workspaces ──
105
118
  createWorkspace: flat('POST', '/api/workspaces'),
106
119
  /** One workspace by id, archived included — what `/api/state` deliberately leaves out. */
@@ -140,7 +153,7 @@ export const routes = {
140
153
  uploadAttachment: param('POST', '/api/sessions/:sessionId/attachments'),
141
154
  /** Copy a chat into a fresh tab beside it, as a Conductor attachment (src/attachments.ts). */
142
155
  splitChat: param('POST', '/api/sessions/:sessionId/split'),
143
- /** Accept a persisted cross-provider job and return before its UI stages run. */
156
+ /** Legacy intake retained only to reject new ordinary-chat delegation after migration. */
144
157
  delegateTask: param('POST', '/api/sessions/:sessionId/delegate'),
145
158
  /** Dismiss a prompt parked behind the lock screen (src/parked.ts). */
146
159
  dismissParkedPrompt: param('DELETE', '/api/sessions/:sessionId/prompt')
@@ -6,7 +6,9 @@ import { ConductorDb } from "./db.js";
6
6
  import { HIT_CLOSE, HIT_OPEN, matchQuery } from "./search.js";
7
7
  import { parseMessage } from "./transcript.js";
8
8
  /** Bump when extraction or an invariant such as one-chunk-per-source changes stored rows. */
9
- const SCHEMA_VERSION = 2;
9
+ // v3 rebuilds every chunk through WorkflowSecretScrubber so a capability indexed by
10
+ // an older relay cannot survive after the private-envelope boundary is introduced.
11
+ const SCHEMA_VERSION = 3;
10
12
  /**
11
13
  * Source rows advanced per tick. The cursor moves by *scanned* rowid rather than
12
14
  * matched rowid, so a caught-up index re-scans nothing.