conductor-remote 1.106.3 → 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.
@@ -13,8 +13,6 @@
13
13
  * Each of these was a second copy before, and each copy was a way for two screens to
14
14
  * disagree about the same workspace.
15
15
  */
16
- /** Stable delimiter around the user-authored part of a server-expanded Workflow prompt. */
17
- export const WORKFLOW_OBJECTIVE_HEADING = '## Workflow objective';
18
16
  /**
19
17
  * Parse the duration grammar accepted by `conductor-remote nosleep`: `90m`,
20
18
  * `2h`, `30s`, or bare seconds. Kept here so the CLI and phone cannot drift.
@@ -28,6 +26,59 @@ export function parseDurationSeconds(raw) {
28
26
  const seconds = amount * (unit === 'h' ? 3600 : unit === 'm' ? 60 : 1);
29
27
  return Number.isSafeInteger(seconds) ? seconds : null;
30
28
  }
29
+ /** Stable delimiter around the user-authored part of a server-expanded Workflow prompt. */
30
+ export const WORKFLOW_OBJECTIVE_HEADING = '## Workflow objective';
31
+ /**
32
+ * Private orchestration metadata is deliberately recognizable at every relay read
33
+ * boundary. The model needs this block in its original Conductor context, while the
34
+ * phone, search index, transcript attachments, logs, and MCP reads must never repeat
35
+ * it. Keep one versioned marker rather than teaching each consumer about individual
36
+ * fields as the state machine grows.
37
+ */
38
+ export const WORKFLOW_PRIVATE_ENVELOPE_VERSION = 1;
39
+ export const WORKFLOW_PRIVATE_ENVELOPE_OPEN = `<conductor-remote-workflow-private version="${WORKFLOW_PRIVATE_ENVELOPE_VERSION}">`;
40
+ export const WORKFLOW_PRIVATE_ENVELOPE_CLOSE = '</conductor-remote-workflow-private>';
41
+ export const WORKFLOW_PRIVATE_REDACTION = '[Workflow orchestration metadata hidden]';
42
+ /** Prefix on the random 256-bit bearer values issued by the Workflow coordinator. */
43
+ export const WORKFLOW_CAPABILITY_PREFIX = `crwf_v${WORKFLOW_PRIVATE_ENVELOPE_VERSION}_`;
44
+ /**
45
+ * Add a non-secret version prefix to 32 bytes of base64url entropy. Besides making
46
+ * accidental disclosure recognizable, the prefix lets a future protocol rotate its
47
+ * scrub rule without treating arbitrary long identifiers as capabilities.
48
+ */
49
+ export function workflowCapabilityToken(base64urlEntropy) {
50
+ if (!/^[A-Za-z0-9_-]{43}$/.test(base64urlEntropy)) {
51
+ throw new Error('Workflow capability entropy must be 32 bytes encoded as base64url.');
52
+ }
53
+ return `${WORKFLOW_CAPABILITY_PREFIX}${base64urlEntropy}`;
54
+ }
55
+ /** The opaque, machine-readable block delivered only into the root model's context. */
56
+ export function workflowPrivateEnvelope(value) {
57
+ return [
58
+ WORKFLOW_PRIVATE_ENVELOPE_OPEN,
59
+ JSON.stringify({
60
+ workflow_id: value.workflowId,
61
+ phase_capability: value.phaseCapability,
62
+ cycle: value.cycle,
63
+ revision: value.revision,
64
+ allowed_roles: [...value.allowedRoles]
65
+ }),
66
+ WORKFLOW_PRIVATE_ENVELOPE_CLOSE
67
+ ].join('\n');
68
+ }
69
+ /**
70
+ * Remove Workflow bearer material from anything leaving the relay's private
71
+ * orchestration boundary. An unterminated opening marker consumes the rest of the
72
+ * string: losing display text is safer than exposing a capability after a truncated
73
+ * database row or error. The loose-token pass also covers rendered tool arguments,
74
+ * where the model necessarily repeats the capability outside its original envelope.
75
+ */
76
+ export function scrubWorkflowSecrets(text) {
77
+ return text
78
+ .replace(/<conductor-remote-workflow-private\b[^>]*>[\s\S]*?<\/conductor-remote-workflow-private>/gi, WORKFLOW_PRIVATE_REDACTION)
79
+ .replace(/<conductor-remote-workflow-private\b[^>]*>[\s\S]*$/gi, WORKFLOW_PRIVATE_REDACTION)
80
+ .replace(/(^|[^A-Za-z0-9_-])crwf_v\d+_[A-Za-z0-9_-]{16,}(?![A-Za-z0-9_-])/g, '$1[Workflow capability hidden]');
81
+ }
31
82
  /**
32
83
  * The branch minus its prefix, sentence-cased — Conductor's own fallback title while a
33
84
  * workspace is still in progress. Prefix-agnostic (github_username / custom / none): it
@@ -209,6 +260,25 @@ export function modelCatalogIncludes(model, groups) {
209
260
  const wanted = modelPickerLabel(model);
210
261
  return groups.some(group => group.models.some(candidate => modelPickerLabel(candidate) === wanted));
211
262
  }
263
+ /**
264
+ * Complete picker reads are whole-menu observations, not provider slices. Ignore
265
+ * groups learned only from a successful single-model selection, then use the
266
+ * newest complete observation to describe what Conductor can select.
267
+ */
268
+ export function newestModelSnapshot(groups) {
269
+ let newest = null;
270
+ for (const group of groups) {
271
+ if (!group.models.some(model => modelPickerLabel(model).trim()))
272
+ continue;
273
+ if (group.snapshotAt === null)
274
+ continue;
275
+ const observedAt = group.snapshotAt ?? group.updatedAt;
276
+ const newestObservedAt = newest ? (newest.snapshotAt ?? newest.updatedAt) : -1;
277
+ if (!newest || observedAt >= newestObservedAt)
278
+ newest = group;
279
+ }
280
+ return newest;
281
+ }
212
282
  /** Stable, case-insensitive grouping shared by every model selector. */
213
283
  export function groupModelPickerLabels(models) {
214
284
  const grouped = new Map();
@@ -419,6 +489,13 @@ export function isLockedError(error) {
419
489
  export function withoutWindowEvidence(error) {
420
490
  return error.replace(/\s*\[window server:.*$/s, '').trim();
421
491
  }
492
+ /**
493
+ * Only diagnostic-bearing wire fields lose the AX/window-server tail. Relay log
494
+ * entries use `text`, so they retain the evidence the phone's log viewer needs.
495
+ */
496
+ export function withoutClientWindowEvidence(value, field) {
497
+ return field === 'error' || field === 'message' || field === 'reason' ? withoutWindowEvidence(value) : value;
498
+ }
422
499
  /**
423
500
  * Header naming the push device that sent a request, so the relay can tell which chat
424
501
  * that device has on screen and skip notifying it about that one chat (src/notify.ts).
@@ -8,9 +8,19 @@
8
8
  * tool_result blocks, never the user's own words. Real prompts are the
9
9
  * plain-text rows. Never render an SDK user frame as a user bubble.
10
10
  */
11
- import { isToolResult } from "./shared.js";
11
+ import { isToolResult, scrubWorkflowSecrets } from "./shared.js";
12
12
  const clip = (s, n) => (s.length > n ? `${s.slice(0, n)}…` : s);
13
13
  const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : undefined);
14
+ /** One exit for every human-readable string parseMessage can expose. */
15
+ function scrubEntry(entry) {
16
+ const scrubbed = { ...entry };
17
+ for (const key of ['text', 'detail', 'output', 'subagentLabel', 'tool']) {
18
+ const value = scrubbed[key];
19
+ if (typeof value === 'string')
20
+ scrubbed[key] = scrubWorkflowSecrets(value);
21
+ }
22
+ return scrubbed;
23
+ }
14
24
  /**
15
25
  * Turn Conductor's outbox payload into the same user entry its eventual
16
26
  * `session_messages` row will become. Outbox rows have no transcript rowid yet;
@@ -32,7 +42,7 @@ export function parseOutboxMessage(row) {
32
42
  id: row.message_id,
33
43
  rowid: 0,
34
44
  role: 'user',
35
- text,
45
+ text: scrubWorkflowSecrets(text),
36
46
  ts: row.created_at,
37
47
  queued: true
38
48
  };
@@ -198,14 +208,14 @@ export function parseMessage(row, worktree = null) {
198
208
  if (!content.startsWith('{')) {
199
209
  if (!content.trim())
200
210
  return [];
201
- return [{ ...base, id: row.id, role: 'user', text: content }];
211
+ return [scrubEntry({ ...base, id: row.id, role: 'user', text: content })];
202
212
  }
203
213
  let parsed;
204
214
  try {
205
215
  parsed = JSON.parse(content);
206
216
  }
207
217
  catch {
208
- return [{ ...base, id: row.id, role: 'system', text: clip(content, 200) }];
218
+ return [scrubEntry({ ...base, id: row.id, role: 'system', text: clip(content, 200) })];
209
219
  }
210
220
  const parentToolUseId = str(parsed.parent_tool_use_id);
211
221
  const frameBase = { ...base, ...(parentToolUseId ? { parentToolUseId } : {}) };
@@ -221,17 +231,17 @@ export function parseMessage(row, worktree = null) {
221
231
  if (parsed.type === 'error') {
222
232
  const said = str(parsed.content);
223
233
  if (said)
224
- return [{ ...frameBase, id: row.id, role: 'system', text: clip(said, 200) }];
234
+ return [scrubEntry({ ...frameBase, id: row.id, role: 'system', text: clip(said, 200) })];
225
235
  }
226
236
  const blocks = parsed.message?.content;
227
237
  if (!Array.isArray(blocks)) {
228
238
  if (parsed.type === 'user' || parsed.type === 'assistant')
229
239
  return [];
230
240
  // Unknown frame shape — keep a dim raw dump so Conductor drift stays visible.
231
- return [{ ...frameBase, id: row.id, role: 'system', text: clip(content, 200) }];
241
+ return [scrubEntry({ ...frameBase, id: row.id, role: 'system', text: clip(content, 200) })];
232
242
  }
233
243
  const entries = [];
234
- const push = (e) => entries.push({ ...frameBase, ...e, id: `${row.id}:${entries.length}` });
244
+ const push = (e) => entries.push(scrubEntry({ ...frameBase, ...e, id: `${row.id}:${entries.length}` }));
235
245
  // Images are numbered per row, because that is all a reference needs to find one again
236
246
  // (`toolImageAt`) and a row may hold several results.
237
247
  let imageIndex = 0;
@@ -308,8 +318,8 @@ const HEADINGS = {
308
318
  /** One tool row per line, the shape `read_chat` prints: what it did, then what it did it to. */
309
319
  function toolLine(e) {
310
320
  if (e.error)
311
- return `- [error] ${e.text}`;
312
- return `- [${e.tool ?? 'tool'}] ${e.text}${e.detail ? ` — \`${e.detail}\`` : ''}`;
321
+ return `- [error] ${scrubWorkflowSecrets(e.text)}`;
322
+ return `- [${scrubWorkflowSecrets(e.tool ?? 'tool')}] ${scrubWorkflowSecrets(e.text)}${e.detail ? ` — \`${scrubWorkflowSecrets(e.detail)}\`` : ''}`;
313
323
  }
314
324
  function plural(n, one) {
315
325
  return `${n} ${one}${n === 1 ? '' : 's'}`;
@@ -406,7 +416,7 @@ export function renderTranscript(entries, format) {
406
416
  heading = want;
407
417
  }
408
418
  flushElisions();
409
- out.push(e.role === 'tool' ? toolLine(e) : e.text);
419
+ out.push(e.role === 'tool' ? toolLine(e) : scrubWorkflowSecrets(e.text));
410
420
  kept++;
411
421
  }
412
422
  // Anything dropped after the last kept entry still has to be admitted to.
@@ -0,0 +1,94 @@
1
+ import { processIdentityAlive, terminateRecordedProcessGroup } from "./relay-processes.js";
2
+ const DEFAULT_SETTLE_MS = 750;
3
+ const DEFAULT_RECOVERY_LEASE_MS = 10_000;
4
+ function probeAlive(probe, identity) {
5
+ try {
6
+ return probe(identity);
7
+ }
8
+ catch {
9
+ // A failed inspection cannot prove that a process is gone.
10
+ return true;
11
+ }
12
+ }
13
+ function sameRelay(left, right) {
14
+ return (left.instanceId === right.instanceId &&
15
+ left.pid === right.pid &&
16
+ left.processStartedAt === right.processStartedAt &&
17
+ left.protocolVersion === right.protocolVersion);
18
+ }
19
+ function recordedGroup(identity) {
20
+ if (identity.processGroup === undefined)
21
+ return undefined;
22
+ return { pid: identity.pid, processStartedAt: identity.processStartedAt, processGroup: identity.processGroup };
23
+ }
24
+ /**
25
+ * Recover one expired process-shared UI lease without ever using time as proof
26
+ * that its owner or detached helper is dead.
27
+ *
28
+ * A live owner is never stolen. This relay may terminate its own overdue helper,
29
+ * while a successor may terminate a proven helper only after the relay owner is
30
+ * dead. Reclamation then uses the database CAS path, which creates persistent
31
+ * quarantine for a lease that crossed mayExecute. The temporary recovery lease
32
+ * is intentionally interactive: it performs no UI work, and this lets it clear a
33
+ * stale mutex even when that same crash already created the global quarantine.
34
+ */
35
+ export async function recoverExpiredUiLease(db, relay, options = {}) {
36
+ const now = options.now ?? Date.now;
37
+ const probe = options.processProbe ?? processIdentityAlive;
38
+ const terminate = options.terminateGroup ?? terminateRecordedProcessGroup;
39
+ const pause = options.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms)));
40
+ const settleMs = Math.max(0, Math.floor(options.settleMs ?? DEFAULT_SETTLE_MS));
41
+ const recoveryLeaseMs = Math.max(1_000, Math.floor(options.recoveryLeaseMs ?? DEFAULT_RECOVERY_LEASE_MS));
42
+ const observed = db.getUiLeaseOwner();
43
+ if (!observed)
44
+ return { status: 'idle' };
45
+ if (observed.deadlineAt > now())
46
+ return { status: 'not_expired' };
47
+ const ownerAlive = probeAlive(probe, observed);
48
+ const ownedHere = sameRelay(observed, relay);
49
+ if (ownerAlive && !ownedHere)
50
+ return { status: 'owner_alive', owner: observed };
51
+ let terminatedExternal = false;
52
+ if (observed.externalProcess && probeAlive(probe, observed.externalProcess)) {
53
+ const group = recordedGroup(observed.externalProcess);
54
+ if (!group || !(await terminate(group, options.terminateOptions))) {
55
+ return { status: 'external_process_alive', owner: observed };
56
+ }
57
+ terminatedExternal = true;
58
+ }
59
+ // The live owning relay will observe its helper's exit, reconcile the effect,
60
+ // and release its own nonce. A watchdog must never steal even from itself.
61
+ if (ownerAlive) {
62
+ if (terminatedExternal && settleMs > 0)
63
+ await pause(settleMs);
64
+ return terminatedExternal
65
+ ? { status: 'owned_external_terminated', owner: observed }
66
+ : { status: 'owner_alive', owner: observed };
67
+ }
68
+ // An external action can outlive its wrapper by a short interval. Delay the
69
+ // receipt decision, but retain the old mutex throughout this window.
70
+ if (observed.mayExecute && settleMs > 0)
71
+ await pause(settleMs);
72
+ const acquired = db.acquireUiLease({
73
+ owner: relay,
74
+ actionId: `ui-recovery:${observed.nonce}`,
75
+ deadlineAt: now() + recoveryLeaseMs,
76
+ priority: 'interactive'
77
+ });
78
+ if (acquired.status !== 'acquired') {
79
+ return { status: 'changed', owner: observed };
80
+ }
81
+ try {
82
+ const quarantine = db.getUiQuarantine();
83
+ return {
84
+ status: 'reclaimed',
85
+ owner: observed,
86
+ quarantined: quarantine.active &&
87
+ (quarantine.actionId === observed.actionId ||
88
+ (observed.effectId !== undefined && quarantine.effectId === observed.effectId))
89
+ };
90
+ }
91
+ finally {
92
+ db.releaseUiLease(acquired.lease);
93
+ }
94
+ }