@oar-sh/oar 0.9.2 → 0.9.4

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.
Files changed (138) hide show
  1. package/.github/extensions/web-relay/extension.mjs +6 -1
  2. package/.github/extensions/web-relay/skills/tool-activity.mjs +7 -0
  3. package/CHANGELOG.md +336 -0
  4. package/README.md +455 -423
  5. package/bin/oar.js +25 -10
  6. package/package.json +11 -5
  7. package/server/README.md +44 -4
  8. package/server/claude-worker/claude-remote-relay-tool.mjs +99 -0
  9. package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
  10. package/server/claude-worker/claude-session-process.mjs +1073 -69
  11. package/server/claude-worker/claude-session-worker.mjs +45 -4
  12. package/server/claude-worker/claude-turn-publisher.mjs +81 -20
  13. package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
  14. package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
  15. package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
  16. package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
  17. package/server/copilot-worker/copilot-question-bridge.mjs +34 -12
  18. package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
  19. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +113 -14
  20. package/server/copilot-worker/copilot-sdk-session-process.mjs +2349 -498
  21. package/server/copilot-worker/copilot-sdk-session-worker.mjs +82 -20
  22. package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
  23. package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
  24. package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
  25. package/server/db-schema.mjs +28 -0
  26. package/server/grok-worker/acp-client.mjs +92 -6
  27. package/server/grok-worker/grok-turn-runner.mjs +59 -8
  28. package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
  29. package/server/mcp/oar-mcp-server.mjs +473 -0
  30. package/server/migrations/0004-steer-settle-markers.mjs +119 -0
  31. package/server/migrations/0005-remote-relays.mjs +90 -0
  32. package/server/public/app/action-confirmations.js +141 -13
  33. package/server/public/app/api-client.js +125 -2
  34. package/server/public/app/ask-user-view.js +102 -13
  35. package/server/public/app/attachments-view.js +37 -2
  36. package/server/public/app/background-task-timeout-options.mjs +9 -0
  37. package/server/public/app/background-tasks-view.mjs +33 -18
  38. package/server/public/app/bootstrap.js +55 -14
  39. package/server/public/app/composer-control-state.mjs +52 -34
  40. package/server/public/app/conversation-deep-link.mjs +42 -0
  41. package/server/public/app/conversation-draft-sync.mjs +187 -0
  42. package/server/public/app/conversation-list-filter.mjs +86 -0
  43. package/server/public/app/conversation-provider-indicator.mjs +8 -0
  44. package/server/public/app/conversation-view.js +1016 -200
  45. package/server/public/app/emoji-view.js +2 -0
  46. package/server/public/app/font-scaling.js +4 -37
  47. package/server/public/app/host-suspend-ui.js +272 -0
  48. package/server/public/app/journal-view.js +149 -6
  49. package/server/public/app/mention-autocomplete.mjs +298 -0
  50. package/server/public/app/model-selector-options.mjs +1 -1
  51. package/server/public/app/push-settings.js +1 -0
  52. package/server/public/app/pwa-install.js +8 -3
  53. package/server/public/app/question-multi-select.mjs +52 -0
  54. package/server/public/app/relay-prompt-sanitizer.mjs +61 -3
  55. package/server/public/app/remote-relay-origin-view.mjs +76 -0
  56. package/server/public/app/remote-relay-shared.mjs +85 -0
  57. package/server/public/app/remote-relays-settings.js +618 -0
  58. package/server/public/app/remote-relays-store.mjs +126 -0
  59. package/server/public/app/settings-modal.js +12 -8
  60. package/server/public/app/settings-tabs.js +1 -1
  61. package/server/public/app/socket-handlers.js +37 -1
  62. package/server/public/app/steered-turn-merge.mjs +100 -0
  63. package/server/public/app/store.js +126 -15
  64. package/server/public/app/sync-outbox.mjs +16 -8
  65. package/server/public/app/thinking-follow.js +260 -0
  66. package/server/public/app/tool-activity-emoji.mjs +40 -0
  67. package/server/public/app/transcript-viewport-anchor.mjs +210 -0
  68. package/server/public/index.html +463 -17
  69. package/server/public/sw.js +7 -0
  70. package/server/relay-tools.md +7 -1
  71. package/server/repositories/message-repository.mjs +68 -18
  72. package/server/repositories/question-repository.mjs +18 -0
  73. package/server/repositories/remote-relay-repository.mjs +75 -0
  74. package/server/repositories/session-repository.mjs +13 -32
  75. package/server/routes/ask-user-routes.mjs +42 -2
  76. package/server/routes/messages-routes.mjs +572 -56
  77. package/server/routes/remote-relay-routes.mjs +133 -0
  78. package/server/routes/sessions-routes.mjs +378 -84
  79. package/server/server-runtime.mjs +303 -25
  80. package/server/services/github-ci-activity-service.mjs +199 -0
  81. package/server/services/host-suspend-command.mjs +38 -0
  82. package/server/services/host-suspend-service.mjs +254 -0
  83. package/server/services/live-turn-picker.mjs +47 -0
  84. package/server/services/push-dispatch-service.mjs +37 -4
  85. package/server/services/relay-prompt-sanitizer.mjs +17 -2
  86. package/server/services/relay-question-context.mjs +46 -0
  87. package/server/services/remote-relay-caller-context.mjs +341 -0
  88. package/server/services/remote-relay-client.mjs +332 -0
  89. package/server/services/remote-relay-dispatcher.mjs +1500 -0
  90. package/server/services/remote-relay-inbound.mjs +187 -0
  91. package/server/services/remote-relay-pairing.mjs +189 -0
  92. package/server/services/remote-relay-registry-service.mjs +581 -0
  93. package/server/services/sdk-session-import-service.mjs +49 -7
  94. package/server/services/session-worker-launch-service.mjs +131 -12
  95. package/server/services/session-worker-registry-service.mjs +75 -0
  96. package/server/services/session-worker-supervisor-service.mjs +7 -1
  97. package/server/services/session-worker-websocket-service.mjs +23 -1
  98. package/server/services/steer-resend.mjs +45 -0
  99. package/shared/background-task-timeout.mjs +12 -4
  100. package/shared/control-poller.mjs +4 -1
  101. package/shared/question-multi-select.mjs +16 -0
  102. package/shared/relay-stop-failure.mjs +15 -0
  103. package/shared/remote-relay-contract.mjs +539 -0
  104. package/shared/remote-relay-mentions.mjs +129 -0
  105. package/shared/remote-relay-tool-core.mjs +314 -0
  106. package/shared/slash-command-guard.mjs +76 -0
  107. package/shared/steer-note.mjs +66 -0
  108. package/shared/steer-settle-failure.mjs +89 -0
  109. package/shared/steer-settle-markers.mjs +18 -0
  110. package/shared/worker-crash-guard.mjs +40 -25
  111. package/shared/worker-runtime/api-client.mjs +112 -22
  112. package/shared/worker-runtime/heartbeat.mjs +55 -1
  113. package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
  114. package/shared/worker-runtime/worker-websocket-link.mjs +119 -8
  115. package/server/claude-worker/claude-session-test-harness.mjs +0 -250
  116. package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
  117. package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -382
  118. package/server/copilot-worker/fixtures/abort-turn.json +0 -588
  119. package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
  120. package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
  121. package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
  122. package/server/copilot-worker/fixtures/happy-turn.json +0 -566
  123. package/server/copilot-worker/fixtures/quota-turn.json +0 -647
  124. package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
  125. package/server/copilot-worker/fixtures/resume-turn.json +0 -700
  126. package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
  127. package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
  128. package/server/routes/messages-routes-test-harness.mjs +0 -111
  129. package/server/services/fixtures/claude-auth-stub.cmd +0 -1
  130. package/server/services/fixtures/claude-auth-stub.mjs +0 -76
  131. package/server/services/fixtures/claude-auth-stub.sh +0 -4
  132. package/server/services/fixtures/cli-install-stub.cmd +0 -1
  133. package/server/services/fixtures/cli-install-stub.mjs +0 -98
  134. package/server/services/fixtures/cli-install-stub.sh +0 -4
  135. package/server/services/fixtures/grok-stub.cmd +0 -1
  136. package/server/services/fixtures/grok-stub.mjs +0 -89
  137. package/server/services/fixtures/grok-stub.sh +0 -4
  138. package/shared/fixtures/copilot-catalog-raw-2026-09-07.json +0 -2363
@@ -0,0 +1,199 @@
1
+ 'use strict';
2
+
3
+ import { execFile } from 'node:child_process';
4
+
5
+ /**
6
+ * Open GitHub Actions runs for a set of workspace roots, via the `gh` CLI the
7
+ * host already has (same credentials the agents use). Nothing in the relay
8
+ * models CI, so this is a best-effort outside view: a repo whose runs cannot
9
+ * be read counts as "unknown" and blocks for a grace period, after which it is
10
+ * ignored rather than keeping the host awake forever over a GitHub outage.
11
+ */
12
+
13
+ const DEFAULT_CACHE_MS = 20 * 1000;
14
+ const DEFAULT_UNKNOWN_GRACE_MS = 15 * 60 * 1000;
15
+ const DEFAULT_TIMEOUT_MS = 20 * 1000;
16
+ const RUN_LIST_LIMIT = 30;
17
+
18
+ const OPEN_RUN_STATUSES = new Set(['queued', 'in_progress', 'waiting', 'requested', 'pending']);
19
+
20
+ /** `git@github.com:o/r.git`, `https://github.com/o/r`, `ssh://git@github.com/o/r.git` → `o/r`. */
21
+ export function parseGitHubRepoSlug(remoteUrl) {
22
+ const text = String(remoteUrl || '').trim();
23
+ if (!text) return null;
24
+ const match = text.match(/github\.com[:/]+([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i);
25
+ if (!match) return null;
26
+ return `${match[1]}/${match[2]}`;
27
+ }
28
+
29
+ export function isOpenCiRunStatus(status) {
30
+ const value = String(status || '').trim().toLowerCase();
31
+ if (!value) return false;
32
+ return OPEN_RUN_STATUSES.has(value) || value !== 'completed';
33
+ }
34
+
35
+ function execText(execFileImpl, file, args, { cwd = undefined, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
36
+ return new Promise((resolve, reject) => {
37
+ let settled = false;
38
+ try {
39
+ execFileImpl(file, args, { cwd, timeout: timeoutMs, windowsHide: true, maxBuffer: 4 * 1024 * 1024 }, (error, stdout, stderr) => {
40
+ if (settled) return;
41
+ settled = true;
42
+ if (error) {
43
+ const detail = String(stderr || error.message || '').trim().split('\n')[0] || 'command failed';
44
+ reject(new Error(detail.slice(0, 200)));
45
+ return;
46
+ }
47
+ resolve(String(stdout || ''));
48
+ });
49
+ } catch (error) {
50
+ if (settled) return;
51
+ settled = true;
52
+ reject(error);
53
+ }
54
+ });
55
+ }
56
+
57
+ export function createGitHubCiActivityService({
58
+ execFileImpl = execFile,
59
+ now = () => Date.now(),
60
+ cacheMs = DEFAULT_CACHE_MS,
61
+ unknownGraceMs = DEFAULT_UNKNOWN_GRACE_MS,
62
+ timeoutMs = DEFAULT_TIMEOUT_MS,
63
+ logger = console,
64
+ } = {}) {
65
+ // root → { slug: string|null, at }
66
+ const slugByRoot = new Map();
67
+ // slug → { runs, error, at, unknownSince, inFlight }
68
+ const runsBySlug = new Map();
69
+
70
+ async function resolveSlug(root) {
71
+ const key = String(root || '').trim();
72
+ if (!key) return null;
73
+ const cached = slugByRoot.get(key);
74
+ if (cached && cached.at !== null && now() - cached.at < 10 * cacheMs) return cached.slug;
75
+ let slug = null;
76
+ try {
77
+ const url = await execText(execFileImpl, 'git', ['-C', key, 'remote', 'get-url', 'origin'], { timeoutMs });
78
+ slug = parseGitHubRepoSlug(url);
79
+ } catch {
80
+ slug = null;
81
+ }
82
+ slugByRoot.set(key, { slug, at: now() });
83
+ return slug;
84
+ }
85
+
86
+ async function fetchRuns(slug) {
87
+ const entry = runsBySlug.get(slug) || { runs: [], error: null, at: null, unknownSince: null, inFlight: null };
88
+ runsBySlug.set(slug, entry);
89
+ if (entry.inFlight) return entry.inFlight;
90
+ if (entry.at !== null && now() - entry.at < cacheMs) return entry;
91
+ entry.inFlight = (async () => {
92
+ try {
93
+ const out = await execText(execFileImpl, 'gh', [
94
+ 'run', 'list', '-R', slug, '--limit', String(RUN_LIST_LIMIT),
95
+ '--json', 'status,name,headBranch,databaseId,event',
96
+ ], { timeoutMs });
97
+ const parsed = JSON.parse(out || '[]');
98
+ entry.runs = (Array.isArray(parsed) ? parsed : [])
99
+ .filter((run) => run && isOpenCiRunStatus(run.status))
100
+ .map((run) => ({
101
+ id: run.databaseId ?? null,
102
+ name: String(run.name || 'workflow').slice(0, 120),
103
+ headBranch: String(run.headBranch || '').slice(0, 160),
104
+ status: String(run.status || '').slice(0, 40),
105
+ event: String(run.event || '').slice(0, 40),
106
+ }));
107
+ entry.error = null;
108
+ entry.unknownSince = null;
109
+ } catch (error) {
110
+ entry.error = String(error?.message || error || 'gh failed').slice(0, 200);
111
+ if (entry.unknownSince === null) entry.unknownSince = now();
112
+ try { logger.warn?.(`[host-suspend] CI state for ${slug} unknown: ${entry.error}`); } catch {}
113
+ } finally {
114
+ entry.at = now();
115
+ entry.inFlight = null;
116
+ }
117
+ return entry;
118
+ })();
119
+ return entry.inFlight;
120
+ }
121
+
122
+ /**
123
+ * @param {string[]} workspaceRoots
124
+ * @returns {Promise<{ repos: Array<{ repo, runs }>, unknown: Array<{ repo, error, since, ignored }>, blockers: Array<object> }>}
125
+ */
126
+ async function describeRuns(workspaceRoots = []) {
127
+ const roots = [...new Set((Array.isArray(workspaceRoots) ? workspaceRoots : []).map((r) => String(r || '').trim()).filter(Boolean))];
128
+ const slugs = new Set();
129
+ for (const root of roots) {
130
+ const slug = await resolveSlug(root);
131
+ if (slug) slugs.add(slug);
132
+ }
133
+ const repos = [];
134
+ const unknown = [];
135
+ const blockers = [];
136
+ for (const slug of slugs) {
137
+ const entry = await fetchRuns(slug);
138
+ if (entry.error) {
139
+ const since = entry.unknownSince === null ? now() : entry.unknownSince;
140
+ const ignored = now() - since >= unknownGraceMs;
141
+ unknown.push({ repo: slug, error: entry.error, since: new Date(since).toISOString(), ignored });
142
+ if (!ignored) {
143
+ blockers.push({ kind: 'ci-unknown', detail: `CI state of ${slug} unknown (${entry.error})`, title: slug });
144
+ }
145
+ continue;
146
+ }
147
+ if (entry.runs.length) {
148
+ repos.push({ repo: slug, runs: entry.runs.map((r) => ({ ...r })) });
149
+ const what = entry.runs.slice(0, 4).map((r) => `${r.name} on ${r.headBranch || '?'} (${r.status})`).join(', ');
150
+ blockers.push({ kind: 'ci', title: slug, count: entry.runs.length, detail: what });
151
+ }
152
+ }
153
+ return { repos, unknown, blockers };
154
+ }
155
+
156
+ /**
157
+ * Last known picture without touching git/gh — for synchronous collectors
158
+ * that kick describeRuns() off in the background. A root or repo that has
159
+ * never been resolved yet reports a `ci-checking` blocker so the host does
160
+ * not sleep during the very first lookup.
161
+ */
162
+ function snapshot(workspaceRoots = []) {
163
+ const blockers = [];
164
+ const repos = [];
165
+ const unknown = [];
166
+ const slugs = new Set();
167
+ let checking = 0;
168
+ // Several busy conversations usually share one workspace; count it once.
169
+ const roots = new Set((Array.isArray(workspaceRoots) ? workspaceRoots : []).map((root) => String(root || '').trim()));
170
+ for (const key of roots) {
171
+ if (!key) continue;
172
+ const cached = slugByRoot.get(key);
173
+ if (!cached) { checking += 1; continue; }
174
+ if (cached.slug) slugs.add(cached.slug);
175
+ }
176
+ for (const slug of slugs) {
177
+ const entry = runsBySlug.get(slug);
178
+ if (!entry || entry.at === null) { checking += 1; continue; }
179
+ if (entry.error) {
180
+ const since = entry.unknownSince === null ? now() : entry.unknownSince;
181
+ const ignored = now() - since >= unknownGraceMs;
182
+ unknown.push({ repo: slug, error: entry.error, since: new Date(since).toISOString(), ignored });
183
+ if (!ignored) blockers.push({ kind: 'ci-unknown', title: slug, detail: `CI state of ${slug} unknown (${entry.error})` });
184
+ continue;
185
+ }
186
+ if (entry.runs.length) {
187
+ repos.push({ repo: slug, runs: entry.runs.map((r) => ({ ...r })) });
188
+ const what = entry.runs.slice(0, 4).map((r) => `${r.name} on ${r.headBranch || '?'} (${r.status})`).join(', ');
189
+ blockers.push({ kind: 'ci', title: slug, count: entry.runs.length, detail: what });
190
+ }
191
+ }
192
+ if (checking > 0) {
193
+ blockers.push({ kind: 'ci-checking', count: checking, detail: `Checking CI state of ${checking} workspace${checking === 1 ? '' : 's'}` });
194
+ }
195
+ return { repos, unknown, blockers };
196
+ }
197
+
198
+ return { describeRuns, snapshot, parseGitHubRepoSlug };
199
+ }
@@ -0,0 +1,38 @@
1
+ 'use strict';
2
+
3
+ import { spawn } from 'node:child_process';
4
+
5
+ /**
6
+ * The actual suspend-to-RAM call. Windows only: `SetSuspendState 0,0,0` puts
7
+ * the box into S3 (hibernation is not enabled on the host, so this never
8
+ * hibernates). Detached so the relay does not wait on it.
9
+ *
10
+ * `OAR_HOST_SUSPEND_DRY_RUN=1` logs instead of sleeping the machine, which is
11
+ * how the deferred-suspend flow gets exercised on a dev box.
12
+ */
13
+ export function runHostSuspendToRam({
14
+ platform = process.platform,
15
+ spawnImpl = spawn,
16
+ dryRun = /^(1|true|yes|on)$/i.test(String(process.env.OAR_HOST_SUSPEND_DRY_RUN || '')),
17
+ logger = console,
18
+ } = {}) {
19
+ if (platform !== 'win32') {
20
+ return { ok: false, statusCode: 501, error: 'Host suspend is only supported on Windows' };
21
+ }
22
+ const command = 'rundll32.exe powrprof.dll,SetSuspendState 0,0,0';
23
+ if (dryRun) {
24
+ try { logger.log?.(`[host-suspend] DRY RUN — would run: ${command}`); } catch {}
25
+ return { ok: true, command, dryRun: true };
26
+ }
27
+ try {
28
+ const child = spawnImpl('rundll32.exe', ['powrprof.dll,SetSuspendState', '0,0,0'], {
29
+ detached: true,
30
+ stdio: 'ignore',
31
+ windowsHide: true,
32
+ });
33
+ child.unref?.();
34
+ return { ok: true, command };
35
+ } catch (error) {
36
+ return { ok: false, statusCode: 500, error: error?.message || 'Failed to launch host suspend command' };
37
+ }
38
+ }
@@ -0,0 +1,254 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Deferred host suspend: a "💤 Suspend host" request is queued and fulfilled
5
+ * only once nothing is running any more — no queued/processing turn, no live
6
+ * background task, no open CI run of a watched workspace — for a sustained
7
+ * idle window. Idle at request time means a short countdown instead, so the
8
+ * banner still gives the user a moment to cancel.
9
+ *
10
+ * Everything lives in memory: a relay restart drops the request (dispose()
11
+ * reports it so the caller can push a notice). The service is pure
12
+ * orchestration; the activity collector, the suspend command and the timers
13
+ * are injected so the state machine is unit-testable with fake clocks.
14
+ */
15
+
16
+ export const HOST_SUSPEND_IDLE_WINDOW_MS = 2 * 60 * 1000;
17
+ export const HOST_SUSPEND_IDLE_COUNTDOWN_MS = 30 * 1000;
18
+ const DEFAULT_POLL_MS = 1000;
19
+
20
+ export const HOST_SUSPEND_STATUSES = Object.freeze(['idle', 'queued', 'countdown', 'suspending']);
21
+
22
+ function clampText(value, max, fallback) {
23
+ const text = String(value ?? '').trim().slice(0, max);
24
+ return text || fallback;
25
+ }
26
+
27
+ /**
28
+ * Normalise a blocker so the client can render it without guessing. Blockers
29
+ * with the same `kind` and `detail` are treated as identical when deciding
30
+ * whether the state changed.
31
+ */
32
+ export function normalizeHostActivityBlocker(raw) {
33
+ if (!raw || typeof raw !== 'object') return null;
34
+ const kind = String(raw.kind || '').trim();
35
+ if (!kind) return null;
36
+ const count = Number(raw.count);
37
+ return {
38
+ kind,
39
+ conversationId: raw.conversationId ? String(raw.conversationId) : null,
40
+ title: raw.title ? String(raw.title).slice(0, 160) : null,
41
+ count: Number.isFinite(count) && count > 0 ? Math.trunc(count) : null,
42
+ detail: String(raw.detail || '').slice(0, 400),
43
+ };
44
+ }
45
+
46
+ export function blockersFromActivity(activity) {
47
+ const list = Array.isArray(activity?.blockers) ? activity.blockers : Array.isArray(activity) ? activity : [];
48
+ return list.map(normalizeHostActivityBlocker).filter(Boolean);
49
+ }
50
+
51
+ export function createHostSuspendService({
52
+ collectActivity,
53
+ runSuspend,
54
+ onStateChange = null,
55
+ onSuspended = null,
56
+ onDropped = null,
57
+ logger = console,
58
+ now = () => Date.now(),
59
+ setIntervalImpl = setInterval,
60
+ clearIntervalImpl = clearInterval,
61
+ pollMs = DEFAULT_POLL_MS,
62
+ idleWindowMs = HOST_SUSPEND_IDLE_WINDOW_MS,
63
+ idleCountdownMs = HOST_SUSPEND_IDLE_COUNTDOWN_MS,
64
+ } = {}) {
65
+ if (typeof collectActivity !== 'function') throw new Error('collectActivity is required');
66
+ if (typeof runSuspend !== 'function') throw new Error('runSuspend is required');
67
+
68
+ let status = 'idle';
69
+ let request = null;
70
+ let blockers = [];
71
+ let idleSince = null;
72
+ let fireAt = null;
73
+ let everBlocked = false;
74
+ let checkedAt = null;
75
+ let lastError = null;
76
+ let pollTimer = null;
77
+ let lastEmittedKey = '';
78
+ let ticking = false;
79
+
80
+ function publicState() {
81
+ return {
82
+ status,
83
+ pending: status === 'queued' || status === 'countdown' || status === 'suspending',
84
+ reason: request?.reason || null,
85
+ requestedBy: request?.requestedBy || null,
86
+ requestedAt: request?.requestedAt || null,
87
+ idleSince: idleSince ? new Date(idleSince).toISOString() : null,
88
+ fireAt: fireAt ? new Date(fireAt).toISOString() : null,
89
+ idleWindowMs: everBlocked ? idleWindowMs : idleCountdownMs,
90
+ checkedAt: checkedAt ? new Date(checkedAt).toISOString() : null,
91
+ blockers: blockers.map((b) => ({ ...b })),
92
+ lastError,
93
+ };
94
+ }
95
+
96
+ function emit(force = false) {
97
+ const state = publicState();
98
+ // Countdowns tick on the client from fireAt; only real transitions go out.
99
+ const key = JSON.stringify([
100
+ state.status,
101
+ state.requestedAt,
102
+ state.fireAt,
103
+ state.blockers.map((b) => `${b.kind}|${b.conversationId}|${b.count}|${b.detail}`),
104
+ state.lastError,
105
+ ]);
106
+ if (!force && key === lastEmittedKey) return;
107
+ lastEmittedKey = key;
108
+ try { onStateChange?.(state); } catch (error) {
109
+ try { logger.warn?.(`[host-suspend] state listener failed: ${error?.message || error}`); } catch {}
110
+ }
111
+ }
112
+
113
+ function stopPolling() {
114
+ if (!pollTimer) return;
115
+ try { clearIntervalImpl(pollTimer); } catch {}
116
+ pollTimer = null;
117
+ }
118
+
119
+ function resetToIdle() {
120
+ status = 'idle';
121
+ request = null;
122
+ blockers = [];
123
+ idleSince = null;
124
+ fireAt = null;
125
+ everBlocked = false;
126
+ lastError = null;
127
+ stopPolling();
128
+ }
129
+
130
+ function collect() {
131
+ try {
132
+ const activity = collectActivity({ request: request ? { ...request } : null });
133
+ lastError = null;
134
+ return blockersFromActivity(activity);
135
+ } catch (error) {
136
+ // An unreadable activity picture must not sleep the box under running
137
+ // agents: report it as a blocker until the collector recovers.
138
+ lastError = String(error?.message || error || 'activity check failed').slice(0, 300);
139
+ return [{
140
+ kind: 'error',
141
+ conversationId: null,
142
+ title: null,
143
+ count: null,
144
+ detail: `Activity check failed: ${lastError}`,
145
+ }];
146
+ }
147
+ }
148
+
149
+ function evaluate() {
150
+ if (status !== 'queued' && status !== 'countdown') return;
151
+ const at = now();
152
+ checkedAt = at;
153
+ blockers = collect();
154
+ if (blockers.length) {
155
+ everBlocked = true;
156
+ idleSince = null;
157
+ fireAt = null;
158
+ status = 'queued';
159
+ emit();
160
+ return;
161
+ }
162
+ if (!idleSince) idleSince = at;
163
+ const windowMs = everBlocked ? idleWindowMs : idleCountdownMs;
164
+ fireAt = idleSince + windowMs;
165
+ status = 'countdown';
166
+ if (at >= fireAt) {
167
+ fire();
168
+ return;
169
+ }
170
+ emit();
171
+ }
172
+
173
+ function fire() {
174
+ status = 'suspending';
175
+ stopPolling();
176
+ const state = publicState();
177
+ emit(true);
178
+ try { logger.log?.(`[host-suspend] everything idle for ${Math.round((state.idleWindowMs || 0) / 1000)} s; suspending host (requested by ${state.requestedBy || 'unknown'})`); } catch {}
179
+ let result = null;
180
+ try {
181
+ result = runSuspend({ state });
182
+ } catch (error) {
183
+ lastError = String(error?.message || error || 'suspend failed').slice(0, 300);
184
+ try { logger.warn?.(`[host-suspend] suspend command failed: ${lastError}`); } catch {}
185
+ }
186
+ const failed = result && result.ok === false;
187
+ if (failed && !lastError) lastError = String(result.error || 'suspend failed').slice(0, 300);
188
+ const finalError = lastError;
189
+ resetToIdle();
190
+ lastError = finalError;
191
+ emit(true);
192
+ if (!finalError) {
193
+ try { onSuspended?.(state); } catch {}
194
+ }
195
+ }
196
+
197
+ function tick() {
198
+ if (ticking) return;
199
+ ticking = true;
200
+ try { evaluate(); } finally { ticking = false; }
201
+ }
202
+
203
+ function requestSuspend({ reason = 'manual-suspend', requestedBy = 'localhost-api' } = {}) {
204
+ if (status === 'suspending') return { accepted: false, state: publicState() };
205
+ if (status === 'queued' || status === 'countdown') {
206
+ return { accepted: false, alreadyPending: true, state: publicState() };
207
+ }
208
+ request = {
209
+ reason: clampText(reason, 140, 'manual-suspend'),
210
+ requestedBy: clampText(requestedBy, 80, 'localhost-api'),
211
+ requestedAt: new Date(now()).toISOString(),
212
+ };
213
+ everBlocked = false;
214
+ idleSince = null;
215
+ fireAt = null;
216
+ lastError = null;
217
+ status = 'queued';
218
+ evaluate();
219
+ if (status === 'queued' || status === 'countdown') {
220
+ pollTimer = setIntervalImpl(tick, pollMs);
221
+ pollTimer?.unref?.();
222
+ }
223
+ return { accepted: true, state: publicState() };
224
+ }
225
+
226
+ function cancel({ requestedBy = 'localhost-api' } = {}) {
227
+ if (status !== 'queued' && status !== 'countdown') {
228
+ return { cancelled: false, state: publicState() };
229
+ }
230
+ try { logger.log?.(`[host-suspend] queued suspend cancelled by ${clampText(requestedBy, 80, 'unknown')}`); } catch {}
231
+ resetToIdle();
232
+ emit(true);
233
+ return { cancelled: true, state: publicState() };
234
+ }
235
+
236
+ function dispose({ reason = 'shutdown' } = {}) {
237
+ const wasPending = status === 'queued' || status === 'countdown';
238
+ const state = publicState();
239
+ resetToIdle();
240
+ if (wasPending) {
241
+ try { logger.log?.(`[host-suspend] queued suspend dropped (${reason})`); } catch {}
242
+ try { onDropped?.(state, reason); } catch {}
243
+ }
244
+ }
245
+
246
+ return {
247
+ request: requestSuspend,
248
+ cancel,
249
+ getState: publicState,
250
+ /** Re-run the activity check now (used by the status/GET routes). */
251
+ refresh() { if (status === 'queued' || status === 'countdown') tick(); return publicState(); },
252
+ dispose,
253
+ };
254
+ }
@@ -0,0 +1,47 @@
1
+ // Which of a conversation's processing queue rows is the LIVE turn.
2
+ //
3
+ // Mid-turn steering means a conversation can have more than one row in
4
+ // `processing` at once: the running turn plus a message the user steered in,
5
+ // which the CLI folds into that turn and which produces no stream or activity
6
+ // of its own — or, after a replay handoff, the row that just took the turn
7
+ // over while the handed-off row is still settling. The live turn is the row
8
+ // that produced output MOST RECENTLY; with no output to separate them, the
9
+ // oldest processing row (the turn a steered message joins). Kept pure so the
10
+ // tie-break rules are testable without a DB.
11
+ //
12
+ // Recency, not volume: relay_stream_events.seq is only unique within one
13
+ // queue_message_id, so comparing it across rows picked "the row with the most
14
+ // events" — a long handed-off turn kept the live bubble (and Stop) from the
15
+ // row that had actually taken over.
16
+
17
+ /**
18
+ * @param {Array<{id:string, lastOutputAtMs?:number, streamDone?:boolean, processingAtMs?:number}>} rows
19
+ * Processing rows for one conversation. `lastOutputAtMs` is when the row
20
+ * last wrote a stream snapshot or activity line (0 = never); `streamDone`
21
+ * marks a row whose main stream snapshot is final — a handed-off row
22
+ * settling, whose closing snapshot re-stamps it as the newest write — and
23
+ * ranks it below every row still open; `processingAtMs` is when it entered
24
+ * processing (final tie-break; smaller = older = wins).
25
+ * @returns {string} the live turn's row id, or '' when there are no rows.
26
+ */
27
+ export function pickLiveTurnRowId(rows = []) {
28
+ const list = Array.isArray(rows) ? rows.filter((row) => row && row.id) : [];
29
+ if (!list.length) return '';
30
+ if (list.length === 1) return String(list[0].id);
31
+ const finite = (value) => (Number.isFinite(Number(value)) ? Number(value) : 0);
32
+ let best = null;
33
+ for (const row of list) {
34
+ if (!best) { best = row; continue; }
35
+ if (Boolean(row.streamDone) !== Boolean(best.streamDone)) {
36
+ if (!row.streamDone) best = row;
37
+ continue;
38
+ }
39
+ const output = finite(row.lastOutputAtMs);
40
+ const bestOutput = finite(best.lastOutputAtMs);
41
+ if (output > bestOutput
42
+ || (output === bestOutput && finite(row.processingAtMs) < finite(best.processingAtMs))) {
43
+ best = row;
44
+ }
45
+ }
46
+ return String(best.id);
47
+ }
@@ -10,7 +10,7 @@
10
10
  * reports outcomes through the status-events store instead.
11
11
  */
12
12
 
13
- export const PUSH_EVENT_TYPES = Object.freeze(['question', 'turnComplete', 'turnFailed', 'board', 'cliOffline']);
13
+ export const PUSH_EVENT_TYPES = Object.freeze(['question', 'turnComplete', 'turnFailed', 'board', 'cliOffline', 'hostSuspend']);
14
14
 
15
15
  export const VAPID_PUBLIC_KEY_SETTING = 'push_vapid_public_key';
16
16
  export const VAPID_PRIVATE_KEY_SETTING = 'push_vapid_private_key';
@@ -34,8 +34,13 @@ const EVENT_LABELS = Object.freeze({
34
34
  turnFailed: { title: 'Turn failed', generic: 'The agent turn failed.' },
35
35
  board: { title: 'Plan ready for review', generic: 'The agent posted a board for review.' },
36
36
  cliOffline: { title: 'CLI offline', generic: 'The relay lost its connection to the CLI host.' },
37
+ hostSuspend: { title: 'Host suspend', generic: 'The queued host suspend changed state.' },
37
38
  });
38
39
 
40
+ // System events carry their own body: no conversation text to preview, and
41
+ // the body is the message itself regardless of the preview preference.
42
+ const SYSTEM_BODY_TYPES = new Set(['hostSuspend']);
43
+
39
44
  function clampInt(value, min, max, fallback) {
40
45
  const numeric = Number(value);
41
46
  if (!Number.isFinite(numeric)) return fallback;
@@ -90,7 +95,9 @@ export function renderPushNotification(event, preferences) {
90
95
  let body = labels.generic;
91
96
  const text = String(event?.body || '').trim();
92
97
  // cliOffline has no conversation text to preview; the generic body is the message.
93
- if (text && type !== 'cliOffline') {
98
+ if (text && SYSTEM_BODY_TYPES.has(type)) {
99
+ body = text.slice(0, MAX_FULL_PREVIEW_CHARS);
100
+ } else if (text && type !== 'cliOffline') {
94
101
  if (prefs.content.preview === 'full') {
95
102
  body = text.slice(0, MAX_FULL_PREVIEW_CHARS);
96
103
  } else if (prefs.content.preview === 'truncated') {
@@ -300,6 +307,23 @@ export function createPushDispatchService({
300
307
  } catch {}
301
308
  }
302
309
 
310
+ // A turn another relay's agent started (its user message carries `origin`)
311
+ // pushes no "reply ready" or "failed": that agent is the one waiting for the
312
+ // reply and reports back to the user itself (decision 22). Question pushes
313
+ // are unchanged. Prepared lazily: a schema without the column (before
314
+ // migration 0005) simply means "not an agent's turn".
315
+ let messageOriginStatement = null;
316
+ function turnStartedByRemoteAgent(sourceMessageId) {
317
+ const id = String(sourceMessageId || '').trim();
318
+ if (!id) return false;
319
+ try {
320
+ messageOriginStatement ??= db.prepare(`SELECT origin_json FROM messages WHERE id = ?`);
321
+ return String(messageOriginStatement.get(id)?.origin_json || '').trim() !== '';
322
+ } catch {
323
+ return false;
324
+ }
325
+ }
326
+
303
327
  async function sendToSubscription(row, event) {
304
328
  const preferences = normalizePushPreferences(parseJsonObject(row.preferences_json));
305
329
  const payload = renderPushNotification(event, preferences);
@@ -412,7 +436,10 @@ export function createPushDispatchService({
412
436
  });
413
437
  }
414
438
 
415
- function notifyTurnComplete({ conversationId, messageId, text } = {}) {
439
+ // `messageId` is the reply; `sourceMessageId` the user message that started
440
+ // the turn.
441
+ function notifyTurnComplete({ conversationId, messageId, sourceMessageId, text } = {}) {
442
+ if (turnStartedByRemoteAgent(sourceMessageId)) return Promise.resolve({ outcome: 'remote-agent' });
416
443
  return dispatch({
417
444
  type: 'turnComplete',
418
445
  conversationId: conversationId || null,
@@ -421,7 +448,8 @@ export function createPushDispatchService({
421
448
  });
422
449
  }
423
450
 
424
- function notifyTurnFailed({ conversationId, messageId, text } = {}) {
451
+ function notifyTurnFailed({ conversationId, messageId, sourceMessageId, text } = {}) {
452
+ if (turnStartedByRemoteAgent(sourceMessageId)) return Promise.resolve({ outcome: 'remote-agent' });
425
453
  return dispatch({
426
454
  type: 'turnFailed',
427
455
  conversationId: conversationId || null,
@@ -443,7 +471,12 @@ export function createPushDispatchService({
443
471
  return dispatch({ type: 'cliOffline' });
444
472
  }
445
473
 
474
+ function notifyHostSuspend({ body = null } = {}) {
475
+ return dispatch({ type: 'hostSuspend', body: body || null, data: { hostSuspendAt: Date.now() } });
476
+ }
477
+
446
478
  return {
479
+ notifyHostSuspend,
447
480
  listDevices,
448
481
  getDevice,
449
482
  upsertSubscription,
@@ -135,6 +135,21 @@ const MEDIA_EMBED_BLOCK_PATTERN = new RegExp(
135
135
  'gi',
136
136
  );
137
137
 
138
+ // A steered message's hidden note (shared/steer-note.mjs) leads the prompt the
139
+ // runtime stored, right after any mode marker. Matched by its stable opening
140
+ // words, because the browser copy of this module cannot import shared/.
141
+ const STEER_NOTE_PATTERN = /^\s*\[Sent while you were still working on my previous message\.[^\]\n]*\]\s*/;
142
+
143
+ // The Claude worker's slash-command guard (shared/slash-command-guard.mjs): a
144
+ // zero-width space in front of a "/x" it delivers as text. trim() keeps it, so
145
+ // it is matched on its own, and only where it guards a "/".
146
+ const SLASH_COMMAND_GUARD_PATTERN = /^\u200B(?=\s*\/)/;
147
+
148
+ function stripLeadingRelayNotes(text) {
149
+ const stripped = String(text || '').replace(STEER_NOTE_PATTERN, '').replace(SLASH_COMMAND_GUARD_PATTERN, '');
150
+ return stripped.trim() ? stripped.trim() : String(text || '').trim();
151
+ }
152
+
138
153
  export function stripRelayPromptContext(text, relayMode = '', attachments = []) {
139
154
  const value = stripAttachmentPromptArtifacts(text)
140
155
  .replace(MEDIA_EMBED_BLOCK_PATTERN, '')
@@ -143,7 +158,7 @@ export function stripRelayPromptContext(text, relayMode = '', attachments = [])
143
158
  const patterns = buildPromptPrefixPatterns(relayMode);
144
159
  for (const pattern of patterns) {
145
160
  const stripped = value.replace(pattern, '').trim();
146
- if (stripped && stripped !== value) return stripped;
161
+ if (stripped && stripped !== value) return stripLeadingRelayNotes(stripped);
147
162
  }
148
- return value;
163
+ return stripLeadingRelayNotes(value);
149
164
  }