@phnx-labs/agents-cli 1.22.26 → 1.22.28
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/CHANGELOG.md +293 -0
- package/README.md +80 -27
- package/dist/bin/agents +0 -0
- package/dist/commands/bench.d.ts +2 -0
- package/dist/commands/bench.js +101 -0
- package/dist/commands/doctor.js +24 -10
- package/dist/commands/exec.js +71 -26
- package/dist/commands/hosts.js +4 -3
- package/dist/commands/insights.js +39 -5
- package/dist/commands/output.js +100 -25
- package/dist/commands/perf.d.ts +10 -0
- package/dist/commands/perf.js +14 -6
- package/dist/commands/projects.js +24 -10
- package/dist/commands/resume.d.ts +11 -0
- package/dist/commands/resume.js +51 -0
- package/dist/commands/routines.js +31 -4
- package/dist/commands/run-account-picker.d.ts +37 -0
- package/dist/commands/run-account-picker.js +101 -17
- package/dist/commands/secrets.js +86 -2
- package/dist/commands/sessions-picker.js +12 -7
- package/dist/commands/sessions.d.ts +73 -7
- package/dist/commands/sessions.js +391 -49
- package/dist/commands/ssh.js +184 -11
- package/dist/commands/teams.js +22 -11
- package/dist/index.js +16 -20
- package/dist/lib/activity.js +45 -99
- package/dist/lib/auth-health.d.ts +47 -3
- package/dist/lib/auth-health.js +74 -15
- package/dist/lib/bench/index.d.ts +4 -0
- package/dist/lib/bench/index.js +4 -0
- package/dist/lib/bench/runner.d.ts +16 -0
- package/dist/lib/bench/runner.js +111 -0
- package/dist/lib/bench/schema.d.ts +5 -0
- package/dist/lib/bench/schema.js +91 -0
- package/dist/lib/bench/storage.d.ts +5 -0
- package/dist/lib/bench/storage.js +32 -0
- package/dist/lib/bench/types.d.ts +40 -0
- package/dist/lib/bench/types.js +1 -0
- package/dist/lib/claude-account-token.d.ts +2 -0
- package/dist/lib/claude-account-token.js +56 -3
- package/dist/lib/cloud/host.js +1 -0
- package/dist/lib/crabbox/cli.d.ts +2 -0
- package/dist/lib/crabbox/cli.js +2 -0
- package/dist/lib/crabbox/lease.js +7 -1
- package/dist/lib/daemon.js +20 -0
- package/dist/lib/devices/connect.d.ts +2 -0
- package/dist/lib/devices/connect.js +7 -0
- package/dist/lib/devices/doctor-findings.d.ts +4 -1
- package/dist/lib/devices/doctor-findings.js +15 -1
- package/dist/lib/devices/harness-inventory.d.ts +97 -0
- package/dist/lib/devices/harness-inventory.js +0 -0
- package/dist/lib/devices/registry.d.ts +2 -0
- package/dist/lib/devices/resolve-target.d.ts +1 -0
- package/dist/lib/devices/resolve-target.js +9 -2
- package/dist/lib/devices/ssh-config.js +3 -0
- package/dist/lib/devices/windows-ssh-enrollment.d.ts +20 -0
- package/dist/lib/devices/windows-ssh-enrollment.js +98 -0
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/events.js +2 -2
- package/dist/lib/exec.js +18 -8
- package/dist/lib/fleet/apply.js +9 -7
- package/dist/lib/fleet/remote-login.d.ts +4 -3
- package/dist/lib/fleet/remote-login.js +11 -9
- package/dist/lib/gemini-settings.d.ts +0 -1
- package/dist/lib/gemini-settings.js +12 -7
- package/dist/lib/git.d.ts +9 -3
- package/dist/lib/git.js +39 -22
- package/dist/lib/hooks/profile.d.ts +12 -1
- package/dist/lib/hooks/profile.js +6 -1
- package/dist/lib/hooks.d.ts +8 -5
- package/dist/lib/hooks.js +14 -7
- package/dist/lib/hosts/dispatch.d.ts +10 -0
- package/dist/lib/hosts/dispatch.js +85 -25
- package/dist/lib/hosts/logs.js +11 -1
- package/dist/lib/hosts/passthrough.d.ts +27 -0
- package/dist/lib/hosts/passthrough.js +19 -10
- package/dist/lib/hosts/progress.d.ts +12 -1
- package/dist/lib/hosts/progress.js +41 -8
- package/dist/lib/hosts/providers/devices.js +1 -0
- package/dist/lib/hosts/ready.d.ts +45 -4
- package/dist/lib/hosts/ready.js +124 -20
- package/dist/lib/hosts/reconcile.d.ts +2 -1
- package/dist/lib/hosts/reconcile.js +24 -7
- package/dist/lib/hosts/reconnect.d.ts +45 -12
- package/dist/lib/hosts/reconnect.js +94 -36
- package/dist/lib/hosts/registry.d.ts +2 -2
- package/dist/lib/hosts/registry.js +4 -5
- package/dist/lib/hosts/remote-cmd.d.ts +17 -0
- package/dist/lib/hosts/remote-cmd.js +29 -0
- package/dist/lib/hosts/tasks.d.ts +3 -0
- package/dist/lib/hosts/types.d.ts +1 -0
- package/dist/lib/hosts/types.js +3 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/install-menubar.d.ts +9 -6
- package/dist/lib/menubar/install-menubar.js +20 -9
- package/dist/lib/menubar/snapshot.js +15 -2
- package/dist/lib/models.d.ts +42 -6
- package/dist/lib/models.js +55 -153
- package/dist/lib/perf/db.js +16 -3
- package/dist/lib/perf/types.d.ts +12 -1
- package/dist/lib/pricing/cost.d.ts +9 -0
- package/dist/lib/pricing/cost.js +24 -0
- package/dist/lib/pricing/index.d.ts +1 -1
- package/dist/lib/pricing/index.js +1 -1
- package/dist/lib/project-probe.d.ts +22 -3
- package/dist/lib/project-probe.js +105 -17
- package/dist/lib/project-status.d.ts +9 -0
- package/dist/lib/project-status.js +15 -0
- package/dist/lib/redact.js +8 -3
- package/dist/lib/refresh.js +22 -11
- package/dist/lib/remote-agents-json.d.ts +32 -0
- package/dist/lib/remote-agents-json.js +47 -16
- package/dist/lib/resource-profiles.js +1 -2
- package/dist/lib/rotate.d.ts +22 -0
- package/dist/lib/rotate.js +26 -0
- package/dist/lib/routine-notify-owner.d.ts +102 -0
- package/dist/lib/routine-notify-owner.js +232 -0
- package/dist/lib/routines.d.ts +10 -0
- package/dist/lib/runner.d.ts +9 -1
- package/dist/lib/runner.js +182 -17
- package/dist/lib/sandbox.d.ts +0 -2
- package/dist/lib/sandbox.js +2 -19
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/agent.d.ts +19 -3
- package/dist/lib/secrets/agent.js +61 -22
- package/dist/lib/secrets/audit.d.ts +1 -1
- package/dist/lib/secrets/audit.js +2 -0
- package/dist/lib/secrets/bundles.d.ts +3 -3
- package/dist/lib/secrets/bundles.js +15 -5
- package/dist/lib/secrets/filestore.d.ts +2 -0
- package/dist/lib/secrets/filestore.js +4 -0
- package/dist/lib/secrets/lease.d.ts +25 -0
- package/dist/lib/secrets/lease.js +44 -0
- package/dist/lib/secrets/session-store.d.ts +7 -0
- package/dist/lib/secrets/session-store.js +21 -0
- package/dist/lib/self-update.d.ts +20 -5
- package/dist/lib/self-update.js +93 -16
- package/dist/lib/session/active.d.ts +49 -0
- package/dist/lib/session/active.js +175 -11
- package/dist/lib/session/bash-command.d.ts +16 -0
- package/dist/lib/session/bash-command.js +65 -0
- package/dist/lib/session/db.d.ts +23 -2
- package/dist/lib/session/db.js +86 -20
- package/dist/lib/session/discover.d.ts +21 -1
- package/dist/lib/session/discover.js +124 -32
- package/dist/lib/session/insights.d.ts +19 -0
- package/dist/lib/session/insights.js +78 -10
- package/dist/lib/session/recovery.d.ts +17 -1
- package/dist/lib/session/recovery.js +111 -4
- package/dist/lib/session/remote-bundle.js +1 -1
- package/dist/lib/session/remote-list.d.ts +10 -9
- package/dist/lib/session/remote-list.js +25 -23
- package/dist/lib/session/resume-owner.d.ts +55 -0
- package/dist/lib/session/resume-owner.js +69 -0
- package/dist/lib/session/team-filter.d.ts +65 -0
- package/dist/lib/session/team-filter.js +98 -3
- package/dist/lib/session/types.d.ts +33 -0
- package/dist/lib/smart-launch.d.ts +31 -5
- package/dist/lib/smart-launch.js +43 -7
- package/dist/lib/ssh-exec.d.ts +27 -0
- package/dist/lib/ssh-exec.js +34 -1
- package/dist/lib/ssh-tunnel.d.ts +3 -2
- package/dist/lib/ssh-tunnel.js +25 -16
- package/dist/lib/staleness/detectors/skills.d.ts +2 -0
- package/dist/lib/staleness/detectors/skills.js +17 -2
- package/dist/lib/staleness/index.d.ts +10 -1
- package/dist/lib/staleness/index.js +28 -3
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/teams/agents.d.ts +1 -0
- package/dist/lib/teams/agents.js +38 -11
- package/dist/lib/teams/placement-probe.d.ts +1 -1
- package/dist/lib/teams/placement-probe.js +24 -24
- package/dist/lib/teams/remoteWorktree.d.ts +11 -7
- package/dist/lib/teams/remoteWorktree.js +29 -27
- package/dist/lib/tmux/session.d.ts +8 -0
- package/dist/lib/tmux/session.js +22 -0
- package/dist/lib/types.d.ts +2 -0
- package/dist/lib/versions.d.ts +1 -0
- package/dist/lib/versions.js +40 -22
- package/package.json +3 -1
|
@@ -21,11 +21,25 @@
|
|
|
21
21
|
* the raw file count rather than quietly differing.
|
|
22
22
|
*/
|
|
23
23
|
import type { SessionEvent } from './types.js';
|
|
24
|
+
/**
|
|
25
|
+
* Minimum quiet time after the assistant's last event before a user message
|
|
26
|
+
* is classified as an **agent silent stall**: the model went quiet on its own
|
|
27
|
+
* and sat idle until the human resumed. Shorter gaps are normal turn-taking.
|
|
28
|
+
* 5 minutes is long enough to exclude "user typing the next instruction" and
|
|
29
|
+
* short enough to catch "went silent mid-task until I said continue."
|
|
30
|
+
*/
|
|
31
|
+
export declare const SILENT_STALL_SECONDS = 300;
|
|
24
32
|
/** Behavioural facets of one session. Serialized as JSON into `session_insights`. */
|
|
25
33
|
export interface InsightFacets {
|
|
26
34
|
toolCounts: Record<string, number>;
|
|
27
35
|
/** Per-model assistant turn counts. `/insights` has no model dimension at all. */
|
|
28
36
|
models: Record<string, number>;
|
|
37
|
+
/**
|
|
38
|
+
* Silent stalls attributed to the model that last spoke before the idle gap
|
|
39
|
+
* (from the nearest preceding `usage`/message model tag). Some models go
|
|
40
|
+
* quiet more often — this is the laziness split. Key = shortened model id.
|
|
41
|
+
*/
|
|
42
|
+
silentStallsByModel: Record<string, number>;
|
|
29
43
|
languages: Record<string, number>;
|
|
30
44
|
/** Slash commands the user invoked, by name. */
|
|
31
45
|
slashCommands: Record<string, number>;
|
|
@@ -84,6 +98,11 @@ export interface InsightFacets {
|
|
|
84
98
|
* can be re-bucketed for a different display timezone without re-parsing.
|
|
85
99
|
*/
|
|
86
100
|
export declare function computeInsightFacets(events: SessionEvent[], timezoneOffsetMinutes?: number): InsightFacets;
|
|
101
|
+
/**
|
|
102
|
+
* Classify a long quiet gap after the assistant's last event as an agent silent stall.
|
|
103
|
+
* Optionally also marks an explicit resume nudge ("continue", …) after that silence.
|
|
104
|
+
*/
|
|
105
|
+
export declare function classifySilentStall(gapSeconds: number, userText: string, friction: Record<string, number>, corrections: Record<string, number>): void;
|
|
87
106
|
/** Percentile of a numeric sample, nearest-rank. Returns 0 for an empty sample. */
|
|
88
107
|
export declare function percentile(values: number[], p: number): number;
|
|
89
108
|
/** Bucket response gaps for display. Returns every bucket, including empty ones. */
|
|
@@ -47,18 +47,35 @@ const ERROR_CATEGORIES = [
|
|
|
47
47
|
[['exit code', 'command failed', 'error:'], 'Command Failed'],
|
|
48
48
|
];
|
|
49
49
|
/**
|
|
50
|
-
* Gaps longer than this
|
|
51
|
-
*
|
|
50
|
+
* Gaps longer than this leave the reply-latency percentiles (someone left for
|
|
51
|
+
* lunch / overnight). They still count as silent stalls when the assistant
|
|
52
|
+
* was the last speaker — the agent had already stopped before the user left.
|
|
52
53
|
*/
|
|
53
54
|
const GAP_CEILING_SECONDS = 3600;
|
|
55
|
+
/**
|
|
56
|
+
* Minimum quiet time after the assistant's last event before a user message
|
|
57
|
+
* is classified as an **agent silent stall**: the model went quiet on its own
|
|
58
|
+
* and sat idle until the human resumed. Shorter gaps are normal turn-taking.
|
|
59
|
+
* 5 minutes is long enough to exclude "user typing the next instruction" and
|
|
60
|
+
* short enough to catch "went silent mid-task until I said continue."
|
|
61
|
+
*/
|
|
62
|
+
export const SILENT_STALL_SECONDS = 300;
|
|
54
63
|
/** Response-gap buckets, in ascending order. Upper bound is exclusive. */
|
|
55
64
|
const GAP_BUCKETS = [
|
|
56
65
|
['<10s', 10], ['10-30s', 30], ['30s-1m', 60], ['1-2m', 120],
|
|
57
66
|
['2-5m', 300], ['5-15m', 900], ['15-60m', Infinity],
|
|
58
67
|
];
|
|
68
|
+
/** Silent-stall duration buckets (agent idle after its last event). */
|
|
69
|
+
const SILENT_STALL_BUCKETS = [
|
|
70
|
+
// label, min inclusive, max exclusive (Infinity = open)
|
|
71
|
+
['silent stall: 5-15m', 300, 900],
|
|
72
|
+
['silent stall: 15-60m', 900, 3600],
|
|
73
|
+
['silent stall: 1h+', 3600, Infinity],
|
|
74
|
+
];
|
|
59
75
|
function emptyFacets() {
|
|
60
76
|
return {
|
|
61
|
-
toolCounts: {}, models: {},
|
|
77
|
+
toolCounts: {}, models: {}, silentStallsByModel: {}, languages: {},
|
|
78
|
+
slashCommands: {}, errorCategories: {},
|
|
62
79
|
interruptions: 0, responseGaps: [], gapsOverCeiling: 0,
|
|
63
80
|
linesTouchedBefore: 0, linesTouchedAfter: 0, editingToolCalls: 0,
|
|
64
81
|
filesCreated: 0, filesModified: 0, filesDeleted: 0, gitCommits: 0, gitPushes: 0,
|
|
@@ -142,10 +159,18 @@ export function computeInsightFacets(events, timezoneOffsetMinutes = new Date().
|
|
|
142
159
|
// reported p50 by 63% (143s against a true 88s), because fast replies are common and
|
|
143
160
|
// dropping them all shifts the median right. A 0-second reply is a real reply.
|
|
144
161
|
//
|
|
145
|
-
// The upper bound stays
|
|
146
|
-
//
|
|
147
|
-
// the
|
|
162
|
+
// The upper bound stays for *percentiles only*: past an hour the user often left the
|
|
163
|
+
// desk, which is not "reply latency." Those gaps still feed silent-stall classification
|
|
164
|
+
// (the agent had already stopped). `gapsOverCeiling` reports how many so percentiles
|
|
165
|
+
// are never quietly truncated.
|
|
166
|
+
//
|
|
167
|
+
// Silent stall (agent idle): when the gap after the assistant's last event is long
|
|
168
|
+
// enough that the model clearly stopped mid-session and waited for a human nudge
|
|
169
|
+
// ("continue", "keep going", or any later message after minutes of silence). This is
|
|
170
|
+
// the inverse framing of reply latency: same timestamps, attributed to the agent.
|
|
148
171
|
let lastAssistantTs = null;
|
|
172
|
+
/** Shortened model id of the last assistant activity (for stall attribution). */
|
|
173
|
+
let lastAssistantModel = null;
|
|
149
174
|
let lastFailedTool = null;
|
|
150
175
|
for (const e of events) {
|
|
151
176
|
const ts = new Date(e.timestamp).getTime();
|
|
@@ -157,8 +182,14 @@ export function computeInsightFacets(events, timezoneOffsetMinutes = new Date().
|
|
|
157
182
|
case 'usage':
|
|
158
183
|
// shortenModel so the label matches `agents sessions <id>` and `insights mix`
|
|
159
184
|
// rather than printing the raw id beside their shortened one.
|
|
160
|
-
if (e.model)
|
|
161
|
-
|
|
185
|
+
if (e.model) {
|
|
186
|
+
const m = shortenModel(e.model);
|
|
187
|
+
bump(f.models, m);
|
|
188
|
+
// Usage rows are the reliable model tag for Claude turns; keep as
|
|
189
|
+
// "last model" even when the timestamp is missing so a following
|
|
190
|
+
// tool_use still attributes a stall correctly.
|
|
191
|
+
lastAssistantModel = m;
|
|
192
|
+
}
|
|
162
193
|
break;
|
|
163
194
|
case 'error':
|
|
164
195
|
bump(f.errorCategories, categorizeError(e.content ?? e.output ?? ''));
|
|
@@ -175,12 +206,18 @@ export function computeInsightFacets(events, timezoneOffsetMinutes = new Date().
|
|
|
175
206
|
if (e.role === 'assistant') {
|
|
176
207
|
if (hasTs)
|
|
177
208
|
lastAssistantTs = ts;
|
|
209
|
+
if (e.model)
|
|
210
|
+
lastAssistantModel = shortenModel(e.model);
|
|
178
211
|
break;
|
|
179
212
|
}
|
|
180
213
|
if (e.role !== 'user')
|
|
181
214
|
break;
|
|
182
|
-
|
|
183
|
-
|
|
215
|
+
// Synthetic user rows (stop-hook feedback, injected meta) are not a human
|
|
216
|
+
// resume — skip correction/stall classification and leave lastAssistantTs so
|
|
217
|
+
// the next real user message still measures the full idle gap.
|
|
218
|
+
if (e._synthetic)
|
|
219
|
+
break;
|
|
220
|
+
classifyCorrection(e.content ?? '', f.correctionSignals);
|
|
184
221
|
if (hasTs) {
|
|
185
222
|
// Local-time hour. parse.ts falls back to `new Date()` for a record with no
|
|
186
223
|
// timestamp; those are indistinguishable here, but they are rare and would
|
|
@@ -196,6 +233,11 @@ export function computeInsightFacets(events, timezoneOffsetMinutes = new Date().
|
|
|
196
233
|
f.responseGaps.push(gap);
|
|
197
234
|
else if (gap >= GAP_CEILING_SECONDS)
|
|
198
235
|
f.gapsOverCeiling++;
|
|
236
|
+
// Agent silent stall: model stopped; session sat idle until this message.
|
|
237
|
+
if (gap >= SILENT_STALL_SECONDS) {
|
|
238
|
+
classifySilentStall(gap, e.content ?? '', f.frictionSignals, f.correctionSignals);
|
|
239
|
+
bump(f.silentStallsByModel, lastAssistantModel ?? 'unknown');
|
|
240
|
+
}
|
|
199
241
|
}
|
|
200
242
|
}
|
|
201
243
|
lastAssistantTs = null;
|
|
@@ -207,6 +249,8 @@ export function computeInsightFacets(events, timezoneOffsetMinutes = new Date().
|
|
|
207
249
|
break;
|
|
208
250
|
if (hasTs)
|
|
209
251
|
lastAssistantTs = ts;
|
|
252
|
+
if (e.model)
|
|
253
|
+
lastAssistantModel = shortenModel(e.model);
|
|
210
254
|
const args = e.args ?? {};
|
|
211
255
|
const toolName = e.tool ?? '';
|
|
212
256
|
if (/askuserquestion/i.test(toolName))
|
|
@@ -257,6 +301,24 @@ const CORRECTION_PATTERNS = [
|
|
|
257
301
|
[/\b(?:check now|check again|try now|did it work)\b/i, 'check now'],
|
|
258
302
|
[/\b(?:don'?t ask|just do it|run what)\b/i, "don't ask / just do it"],
|
|
259
303
|
];
|
|
304
|
+
/** User text that is a pure resume nudge after the agent went silent. */
|
|
305
|
+
const RESUME_NUDGE_PATTERN = /\b(?:continue|keep going|don'?t stop|resume|pick up|you stopped|still there|wake up|hello\??|are you (?:there|stuck)|go on)\b/i;
|
|
306
|
+
/**
|
|
307
|
+
* Classify a long quiet gap after the assistant's last event as an agent silent stall.
|
|
308
|
+
* Optionally also marks an explicit resume nudge ("continue", …) after that silence.
|
|
309
|
+
*/
|
|
310
|
+
export function classifySilentStall(gapSeconds, userText, friction, corrections) {
|
|
311
|
+
for (const [label, min, max] of SILENT_STALL_BUCKETS) {
|
|
312
|
+
if (gapSeconds >= min && gapSeconds < max) {
|
|
313
|
+
bump(friction, label);
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
const normalized = userText.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
318
|
+
if (normalized && RESUME_NUDGE_PATTERN.test(normalized)) {
|
|
319
|
+
bump(corrections, 'resume after silent stall');
|
|
320
|
+
}
|
|
321
|
+
}
|
|
260
322
|
const AUTOMATION_PATTERNS = [
|
|
261
323
|
[/\bgh pr (?:checks|view|merge)\b/i, 'PR babysitting'],
|
|
262
324
|
[/\bagents secrets (?:list|exec|unlock|export)\b/i, 'secrets unlock dance'],
|
|
@@ -360,6 +422,8 @@ export function mergeFacets(into, add) {
|
|
|
360
422
|
bump(into.toolCounts, k, v);
|
|
361
423
|
for (const [k, v] of Object.entries(add.models))
|
|
362
424
|
bump(into.models, k, v);
|
|
425
|
+
for (const [k, v] of Object.entries(add.silentStallsByModel ?? {}))
|
|
426
|
+
bump(into.silentStallsByModel, k, v);
|
|
363
427
|
for (const [k, v] of Object.entries(add.languages))
|
|
364
428
|
bump(into.languages, k, v);
|
|
365
429
|
for (const [k, v] of Object.entries(add.slashCommands))
|
|
@@ -406,6 +470,7 @@ export function topEntries(counts, limit) {
|
|
|
406
470
|
export function buildInsightActions(sessions) {
|
|
407
471
|
const specs = [
|
|
408
472
|
{ source: 'correctionSignals', label: 'continue / keep going', category: 'rule', action: 'Keep working through the delivery chain without waiting for another “continue”.' },
|
|
473
|
+
{ source: 'correctionSignals', label: 'resume after silent stall', category: 'rule', action: 'Never stop mid-task waiting for a human ping — finish the current goal or park with an explicit blocker; keep driving CI/review/merge without going idle.' },
|
|
409
474
|
{ source: 'correctionSignals', label: 'approval repeated', category: 'rule', action: 'Treat the original build or ship request as authorization for routine follow-through.' },
|
|
410
475
|
{ source: 'correctionSignals', label: 'done end-to-end?', category: 'rule', action: 'Verify the user-visible outcome before declaring the task complete.' },
|
|
411
476
|
{ source: 'correctionSignals', label: 'did you merge?', category: 'automation', action: 'Automate PR review, CI watching, and merge-on-green as one durable workflow.' },
|
|
@@ -416,6 +481,9 @@ export function buildInsightActions(sessions) {
|
|
|
416
481
|
{ source: 'correctionSignals', label: "Ask stall: what's next?", category: 'rule', action: 'Remove workflow-stall “what next?” prompts from agent guidance.' },
|
|
417
482
|
{ source: 'correctionSignals', label: 'Ask stall: merge / reconcile', category: 'skill', action: 'Encode the safe merge and reconcile path in the git workflow skill.' },
|
|
418
483
|
{ source: 'correctionSignals', label: 'Ask stall: direction / approach', category: 'rule', action: 'Let agents choose implementation details after scope is clear.' },
|
|
484
|
+
{ source: 'frictionSignals', label: 'silent stall: 5-15m', category: 'rule', action: 'Stop ending turns while work remains open — after a tool batch, take the next step or schedule a real background wait that re-invokes you; do not sit idle until the user says continue.' },
|
|
485
|
+
{ source: 'frictionSignals', label: 'silent stall: 15-60m', category: 'rule', action: 'Long idle after the assistant last spoke is an agent stop, not a user pause — drive open PRs/CI/todos to completion or name a true external blocker instead of going silent.' },
|
|
486
|
+
{ source: 'frictionSignals', label: 'silent stall: 1h+', category: 'rule', action: 'Sessions that sit idle for an hour+ after the model stops are stranded work — use stop-gates, background watches, and queue drain so a human is not the only resume signal.' },
|
|
419
487
|
{ source: 'frictionSignals', label: 'blocked guard', category: 'product', action: 'Make guard failures return the safe next command and exact blocked operation.' },
|
|
420
488
|
{ source: 'frictionSignals', label: 'CI red loop', category: 'automation', action: 'Deduplicate CI watchers and turn repeated red checks into one stateful wait.' },
|
|
421
489
|
{ source: 'frictionSignals', label: 'merge conflict', category: 'skill', action: 'Standardize conflict diagnosis and fix-forward reconciliation.' },
|
|
@@ -5,6 +5,7 @@ export type SessionRecoveryTarget = {
|
|
|
5
5
|
mode: 'native';
|
|
6
6
|
agent: AgentId;
|
|
7
7
|
version: string;
|
|
8
|
+
cwd?: string;
|
|
8
9
|
reason: string;
|
|
9
10
|
} | {
|
|
10
11
|
mode: 'continue';
|
|
@@ -12,6 +13,13 @@ export type SessionRecoveryTarget = {
|
|
|
12
13
|
version: string;
|
|
13
14
|
reason: string;
|
|
14
15
|
};
|
|
16
|
+
export type NativeResumeInspection = {
|
|
17
|
+
available: true;
|
|
18
|
+
cwd?: string;
|
|
19
|
+
} | {
|
|
20
|
+
available: false;
|
|
21
|
+
reason: string;
|
|
22
|
+
};
|
|
15
23
|
export declare class SessionRecoveryError extends Error {
|
|
16
24
|
constructor(message: string);
|
|
17
25
|
}
|
|
@@ -21,6 +29,14 @@ export declare function sessionOriginDevice(session: Pick<SessionMeta, 'machine'
|
|
|
21
29
|
export declare function sessionRecoveryPeer(session: Pick<SessionMeta, 'machine'>, selfCheck?: (host: string) => boolean): string | undefined;
|
|
22
30
|
/** Whether an explicit placement names the session's origin device. */
|
|
23
31
|
export declare function sessionRecoveryDestinationMatches(session: Pick<SessionMeta, 'machine'>, requestedHost: string, self?: string): boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Prove that the indexed transcript is reachable from the exact active version
|
|
34
|
+
* home that would receive native resume. Retained trash/backup transcripts are
|
|
35
|
+
* intentionally rejected here: they remain readable by `/continue`, but a new
|
|
36
|
+
* installation with the same version number must not native-resume an empty
|
|
37
|
+
* isolated home.
|
|
38
|
+
*/
|
|
39
|
+
export declare function inspectNativeResumeSession(session: SessionMeta, versionHome: string): NativeResumeInspection;
|
|
24
40
|
/**
|
|
25
41
|
* Decide how a durable session resumes on the device that owns it.
|
|
26
42
|
*
|
|
@@ -29,7 +45,7 @@ export declare function sessionRecoveryDestinationMatches(session: Pick<SessionM
|
|
|
29
45
|
* same harness and uses `/continue`, whose indexed transcript reader can reach
|
|
30
46
|
* retained version trash. No healthy same-harness account is a loud failure.
|
|
31
47
|
*/
|
|
32
|
-
export declare function resolveSessionRecoveryFromCandidates(session: SessionMeta, candidates: RotateCandidate[], supportsNative?: (agent: AgentId, version?: string) => boolean): SessionRecoveryTarget;
|
|
48
|
+
export declare function resolveSessionRecoveryFromCandidates(session: SessionMeta, candidates: RotateCandidate[], supportsNative?: (agent: AgentId, version?: string) => boolean, nativeInspection?: NativeResumeInspection): SessionRecoveryTarget;
|
|
33
49
|
export declare function resolveSessionRecovery(session: SessionMeta): Promise<SessionRecoveryTarget>;
|
|
34
50
|
/** Stable self-command used by focus, resume, and attach. The owning device runs
|
|
35
51
|
* the recovery resolver above; callers must not native-resume another version's
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { AGENTS, agentConfigDirName } from '../agents.js';
|
|
2
4
|
import { isSelfHost } from '../devices/self-host.js';
|
|
3
5
|
import { nativeResume } from '../exec.js';
|
|
4
6
|
import { machineId, normalizeHost } from '../machine-id.js';
|
|
5
7
|
import { collectRunCandidates, formatNoHealthyAccountError, pickBalancedCandidate, readinessFromCandidate, } from '../rotate.js';
|
|
8
|
+
import { getVersionHomePath } from '../versions.js';
|
|
6
9
|
export class SessionRecoveryError extends Error {
|
|
7
10
|
constructor(message) {
|
|
8
11
|
super(message);
|
|
@@ -41,6 +44,99 @@ function sourceReason(session, candidates) {
|
|
|
41
44
|
? `origin ${session.agent}@${session.version} has no native resume form`
|
|
42
45
|
: `origin ${session.agent}@${session.version} is ${readiness.reason}`;
|
|
43
46
|
}
|
|
47
|
+
function isPathInside(candidate, dir) {
|
|
48
|
+
const rel = path.relative(dir, candidate);
|
|
49
|
+
return rel === '' || (!!rel && !rel.startsWith('..') && !path.isAbsolute(rel));
|
|
50
|
+
}
|
|
51
|
+
function existingDirectory(dir) {
|
|
52
|
+
if (!dir)
|
|
53
|
+
return undefined;
|
|
54
|
+
try {
|
|
55
|
+
return fs.statSync(dir, { throwIfNoEntry: false })?.isDirectory() ? dir : undefined;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** Read the launch cwd Claude used to choose its projects/<cwd-key> directory.
|
|
62
|
+
* Claude can record attachment envelopes before the first user turn, and those
|
|
63
|
+
* envelopes retain the actual launch cwd even after the session changes dirs. */
|
|
64
|
+
function readClaudeLaunchCwd(filePath) {
|
|
65
|
+
const maxBytes = 2 * 1024 * 1024;
|
|
66
|
+
let fd;
|
|
67
|
+
try {
|
|
68
|
+
fd = fs.openSync(filePath, 'r');
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const chunk = Buffer.alloc(maxBytes);
|
|
75
|
+
const bytesRead = fs.readSync(fd, chunk, 0, maxBytes, 0);
|
|
76
|
+
const lines = chunk.toString('utf8', 0, bytesRead).split('\n');
|
|
77
|
+
for (const line of lines) {
|
|
78
|
+
if (!line.trim())
|
|
79
|
+
continue;
|
|
80
|
+
try {
|
|
81
|
+
const parsed = JSON.parse(line);
|
|
82
|
+
if (typeof parsed?.cwd !== 'string' || !path.isAbsolute(parsed.cwd))
|
|
83
|
+
continue;
|
|
84
|
+
if (existingDirectory(parsed.cwd))
|
|
85
|
+
return parsed.cwd;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// A malformed line or vanished cwd cannot identify a usable native home.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
fs.closeSync(fd);
|
|
94
|
+
}
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Prove that the indexed transcript is reachable from the exact active version
|
|
99
|
+
* home that would receive native resume. Retained trash/backup transcripts are
|
|
100
|
+
* intentionally rejected here: they remain readable by `/continue`, but a new
|
|
101
|
+
* installation with the same version number must not native-resume an empty
|
|
102
|
+
* isolated home.
|
|
103
|
+
*/
|
|
104
|
+
export function inspectNativeResumeSession(session, versionHome) {
|
|
105
|
+
let realFile;
|
|
106
|
+
try {
|
|
107
|
+
realFile = fs.realpathSync(session.filePath);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return { available: false, reason: 'the indexed transcript is no longer present in the origin home' };
|
|
111
|
+
}
|
|
112
|
+
const roots = [versionHome, path.join(versionHome, agentConfigDirName(session.agent))];
|
|
113
|
+
const owned = roots.some((root) => {
|
|
114
|
+
try {
|
|
115
|
+
return isPathInside(realFile, fs.realpathSync(root));
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
if (!owned) {
|
|
122
|
+
return {
|
|
123
|
+
available: false,
|
|
124
|
+
reason: `the indexed transcript is retained outside the active ${session.agent}@${session.version ?? 'unknown'} home`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
if (session.agent === 'claude') {
|
|
128
|
+
const cwd = readClaudeLaunchCwd(realFile);
|
|
129
|
+
if (!cwd) {
|
|
130
|
+
return {
|
|
131
|
+
available: false,
|
|
132
|
+
reason: 'the Claude transcript does not identify an existing original project directory',
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
return { available: true, cwd };
|
|
136
|
+
}
|
|
137
|
+
const cwd = existingDirectory(session.cwd);
|
|
138
|
+
return { available: true, cwd };
|
|
139
|
+
}
|
|
44
140
|
/**
|
|
45
141
|
* Decide how a durable session resumes on the device that owns it.
|
|
46
142
|
*
|
|
@@ -49,7 +145,7 @@ function sourceReason(session, candidates) {
|
|
|
49
145
|
* same harness and uses `/continue`, whose indexed transcript reader can reach
|
|
50
146
|
* retained version trash. No healthy same-harness account is a loud failure.
|
|
51
147
|
*/
|
|
52
|
-
export function resolveSessionRecoveryFromCandidates(session, candidates, supportsNative = nativeResume) {
|
|
148
|
+
export function resolveSessionRecoveryFromCandidates(session, candidates, supportsNative = nativeResume, nativeInspection) {
|
|
53
149
|
const agent = runnableSessionAgent(session);
|
|
54
150
|
const device = sessionOriginDevice(session);
|
|
55
151
|
const source = session.version
|
|
@@ -69,11 +165,22 @@ export function resolveSessionRecoveryFromCandidates(session, candidates, suppor
|
|
|
69
165
|
}
|
|
70
166
|
const version = selection.picked.version;
|
|
71
167
|
if (session.version === version && supportsNative(agent, version)) {
|
|
168
|
+
const inspection = nativeInspection
|
|
169
|
+
?? inspectNativeResumeSession(session, getVersionHomePath(agent, version));
|
|
170
|
+
if (inspection.available) {
|
|
171
|
+
return {
|
|
172
|
+
mode: 'native',
|
|
173
|
+
agent,
|
|
174
|
+
version,
|
|
175
|
+
cwd: inspection.cwd,
|
|
176
|
+
reason: `origin ${agent}@${version} is installed, healthy, and owns the indexed transcript`,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
72
179
|
return {
|
|
73
|
-
mode: '
|
|
180
|
+
mode: 'continue',
|
|
74
181
|
agent,
|
|
75
182
|
version,
|
|
76
|
-
reason:
|
|
183
|
+
reason: `${inspection.reason}; continuing with healthy ${agent}@${version}`,
|
|
77
184
|
};
|
|
78
185
|
}
|
|
79
186
|
return {
|
|
@@ -40,7 +40,7 @@ export async function pullBundlesFromHosts(hosts, exportArgs) {
|
|
|
40
40
|
for (const t of targets) {
|
|
41
41
|
const cmd = remoteAgentsCommand(['sessions', 'export', ...exportArgs, '--stdout'], t.os);
|
|
42
42
|
process.stderr.write(chalk.dim(`Pulling sessions from ${t.name}…\n`));
|
|
43
|
-
const res = sshExec(t.target, cmd, { timeoutMs: REMOTE_EXPORT_TIMEOUT_MS });
|
|
43
|
+
const res = sshExec(t.target, cmd, { timeoutMs: REMOTE_EXPORT_TIMEOUT_MS, extraSshArgs: t.extraSshArgs });
|
|
44
44
|
if (res.timedOut) {
|
|
45
45
|
errors.push(`${t.name}: timed out after ${Math.round(REMOTE_EXPORT_TIMEOUT_MS / 1000)}s`);
|
|
46
46
|
continue;
|
|
@@ -2,20 +2,12 @@ import { type DeviceProfile } from '../devices/registry.js';
|
|
|
2
2
|
import { type RemoteAgentsJsonParseResult } from '../remote-agents-json.js';
|
|
3
3
|
import type { SessionMeta } from './types.js';
|
|
4
4
|
import { type ToolProgramCountEnvelope, type ToolSearchEnvelope } from './tool-index.js';
|
|
5
|
-
export
|
|
5
|
+
export { REMOTE_STDOUT_MAX_BYTES, RemoteUtf8Accumulator } from '../ssh-exec.js';
|
|
6
6
|
export declare const REMOTE_TOOL_AGGREGATE_MAX_BYTES: number;
|
|
7
7
|
export interface RemoteToolByteBudget {
|
|
8
8
|
remainingBytes: number;
|
|
9
9
|
exhausted: boolean;
|
|
10
10
|
}
|
|
11
|
-
/** Preserve UTF-8 code points when SSH splits them across stdout chunks. */
|
|
12
|
-
export declare class RemoteUtf8Accumulator {
|
|
13
|
-
private readonly decoder;
|
|
14
|
-
private value;
|
|
15
|
-
write(chunk: Buffer): void;
|
|
16
|
-
end(): string;
|
|
17
|
-
current(): string;
|
|
18
|
-
}
|
|
19
11
|
/** Claim received bytes against one fleet-query budget before retaining them. */
|
|
20
12
|
export declare function consumeRemoteToolByteBudget(budget: RemoteToolByteBudget, bytes: number): boolean;
|
|
21
13
|
/** Charge sanitized, machine-stamped evidence because redaction may expand it. */
|
|
@@ -130,7 +122,16 @@ export declare function resolvePeerTarget(machine: string): Promise<{
|
|
|
130
122
|
* not via a local `--host` hop, which would discover locally and dead-end for a
|
|
131
123
|
* session that exists only on the peer. Resolves 'no-target' when the machine
|
|
132
124
|
* isn't a dialable registered device; the caller surfaces a clear message.
|
|
125
|
+
*
|
|
126
|
+
* `opts.env` adds variables to the remote command. It deliberately does NOT
|
|
127
|
+
* carry `AGENTS_FLEET_REMOTE` the way the `--host` passthrough does: that marker
|
|
128
|
+
* gates consent-sensitive actions on the far side
|
|
129
|
+
* (lib/browser/remote-control.ts), and a resumed agent is a long-lived session
|
|
130
|
+
* that would inherit it for its whole life — `agents browser start` inside it
|
|
131
|
+
* would then be refused as a cross-machine drive. A one-shot `--host` command
|
|
132
|
+
* can carry the marker; a session cannot.
|
|
133
133
|
*/
|
|
134
134
|
export declare function runOnPeer(args: string[], machine: string, opts?: {
|
|
135
135
|
tty?: boolean;
|
|
136
|
+
env?: Record<string, string>;
|
|
136
137
|
}): Promise<'ok' | 'no-target'>;
|
|
@@ -14,13 +14,12 @@
|
|
|
14
14
|
* never fatal — one asleep laptop must not blank the list.
|
|
15
15
|
*/
|
|
16
16
|
import { spawn } from 'child_process';
|
|
17
|
-
import { StringDecoder } from 'string_decoder';
|
|
18
17
|
import chalk from 'chalk';
|
|
19
|
-
import { SSH_OPTS, controlOpts, assertValidSshTarget, shellQuote } from '../ssh-exec.js';
|
|
18
|
+
import { SSH_OPTS, controlOpts, assertValidSshTarget, shellQuote, REMOTE_STDOUT_MAX_BYTES, RemoteUtf8Accumulator, } from '../ssh-exec.js';
|
|
20
19
|
import { sshTargetFor } from '../devices/connect.js';
|
|
21
20
|
import { resolveExplicitTargetSet } from '../devices/resolve-target.js';
|
|
22
21
|
import { loadDevices, isControlDevice, isDialableDevice } from '../devices/registry.js';
|
|
23
|
-
import { remoteShellFor, buildWindowsAgentsCommand } from '../hosts/remote-cmd.js';
|
|
22
|
+
import { remoteShellFor, buildWindowsAgentsCommand, stripClixml } from '../hosts/remote-cmd.js';
|
|
24
23
|
import { gatherRemoteAgentsJson } from '../remote-agents-json.js';
|
|
25
24
|
import { machineId, normalizeHost } from './sync/config.js';
|
|
26
25
|
import { NO_FANOUT_ENV } from './remote-active.js';
|
|
@@ -30,23 +29,12 @@ import { mapBounded } from '../concurrency.js';
|
|
|
30
29
|
import { TOOL_QUERY_MAX_CLAUSE_BYTES, TOOL_QUERY_MAX_CALL_ROWS, TOOL_QUERY_MAX_CLAUSES, TOOL_QUERY_MAX_RESULT_SESSIONS, TOOL_QUERY_MAX_SERIALIZED_BYTES, serializedToolSearchEnvelopeBytes, } from './tool-index.js';
|
|
31
30
|
import { TOOL_ERROR_OUTPUT_MAX_BYTES, TOOL_INPUT_MAX_BYTES, TOOL_SUCCESS_OUTPUT_MAX_BYTES, sanitizeToolEvidenceText, } from './tool-calls.js';
|
|
32
31
|
const REMOTE_TOOL_TIMEOUT_MS = 60_000;
|
|
33
|
-
|
|
32
|
+
// The per-peer stdout ceiling and the UTF-8-safe accumulator live in ssh-exec.ts
|
|
33
|
+
// (the shared SSH transport both this reader and the top-level `remote-agents-json`
|
|
34
|
+
// fan-out import), so the bound is defined once. Re-exported here for the existing
|
|
35
|
+
// consumers/tests that reach them through this module.
|
|
36
|
+
export { REMOTE_STDOUT_MAX_BYTES, RemoteUtf8Accumulator } from '../ssh-exec.js';
|
|
34
37
|
export const REMOTE_TOOL_AGGREGATE_MAX_BYTES = TOOL_QUERY_MAX_SERIALIZED_BYTES;
|
|
35
|
-
/** Preserve UTF-8 code points when SSH splits them across stdout chunks. */
|
|
36
|
-
export class RemoteUtf8Accumulator {
|
|
37
|
-
decoder = new StringDecoder('utf8');
|
|
38
|
-
value = '';
|
|
39
|
-
write(chunk) {
|
|
40
|
-
this.value += this.decoder.write(chunk);
|
|
41
|
-
}
|
|
42
|
-
end() {
|
|
43
|
-
this.value += this.decoder.end();
|
|
44
|
-
return this.value;
|
|
45
|
-
}
|
|
46
|
-
current() {
|
|
47
|
-
return this.value;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
38
|
/** Claim received bytes against one fleet-query budget before retaining them. */
|
|
51
39
|
export function consumeRemoteToolByteBudget(budget, bytes) {
|
|
52
40
|
if (budget.exhausted || bytes > budget.remainingBytes) {
|
|
@@ -91,7 +79,7 @@ export function remoteListCommand(forwardedArgs, os) {
|
|
|
91
79
|
export function parseRemoteList(stdout, machine) {
|
|
92
80
|
let parsed;
|
|
93
81
|
try {
|
|
94
|
-
parsed = JSON.parse(stdout);
|
|
82
|
+
parsed = JSON.parse(stripClixml(stdout));
|
|
95
83
|
}
|
|
96
84
|
catch {
|
|
97
85
|
return [];
|
|
@@ -117,7 +105,7 @@ function isSafeResolverRow(value) {
|
|
|
117
105
|
export function parseRemoteListPayload(stdout, machine, safeResolver = false) {
|
|
118
106
|
let parsed;
|
|
119
107
|
try {
|
|
120
|
-
parsed = JSON.parse(stdout);
|
|
108
|
+
parsed = JSON.parse(stripClixml(stdout));
|
|
121
109
|
}
|
|
122
110
|
catch {
|
|
123
111
|
return { items: [], valid: false };
|
|
@@ -375,6 +363,10 @@ function parseRemoteToolSession(value, machine) {
|
|
|
375
363
|
};
|
|
376
364
|
}
|
|
377
365
|
export function parseRemoteToolSearch(stdout, machine, expectedClauses) {
|
|
366
|
+
// The fleet tool-search fan-out reads a raw sshCapture (not the stripped
|
|
367
|
+
// gatherRemoteAgentsJson wrapper), so a Windows peer's PowerShell CLIXML banner
|
|
368
|
+
// must be removed here too or the box reads as "no envelope" (RUSH-2286).
|
|
369
|
+
stdout = stripClixml(stdout);
|
|
378
370
|
if (Buffer.byteLength(stdout) > REMOTE_STDOUT_MAX_BYTES)
|
|
379
371
|
return undefined;
|
|
380
372
|
try {
|
|
@@ -540,6 +532,14 @@ export async function resolvePeerTarget(machine) {
|
|
|
540
532
|
* not via a local `--host` hop, which would discover locally and dead-end for a
|
|
541
533
|
* session that exists only on the peer. Resolves 'no-target' when the machine
|
|
542
534
|
* isn't a dialable registered device; the caller surfaces a clear message.
|
|
535
|
+
*
|
|
536
|
+
* `opts.env` adds variables to the remote command. It deliberately does NOT
|
|
537
|
+
* carry `AGENTS_FLEET_REMOTE` the way the `--host` passthrough does: that marker
|
|
538
|
+
* gates consent-sensitive actions on the far side
|
|
539
|
+
* (lib/browser/remote-control.ts), and a resumed agent is a long-lived session
|
|
540
|
+
* that would inherit it for its whole life — `agents browser start` inside it
|
|
541
|
+
* would then be refused as a cross-machine drive. A one-shot `--host` command
|
|
542
|
+
* can carry the marker; a session cannot.
|
|
543
543
|
*/
|
|
544
544
|
export async function runOnPeer(args, machine, opts = {}) {
|
|
545
545
|
const peer = await resolvePeerTarget(machine);
|
|
@@ -547,9 +547,11 @@ export async function runOnPeer(args, machine, opts = {}) {
|
|
|
547
547
|
return 'no-target';
|
|
548
548
|
assertValidSshTarget(peer.target); // registry-sourced, but validate like the fan-out does
|
|
549
549
|
const cols = terminalWidth();
|
|
550
|
+
const env = { ...(cols > 0 ? { COLUMNS: String(cols) } : {}), ...opts.env };
|
|
551
|
+
const assignments = Object.entries(env).map(([k, v]) => `${k}=${shellQuote(v)}`);
|
|
550
552
|
const remoteCmd = remoteShellFor(peer.os) === 'powershell'
|
|
551
|
-
? buildWindowsAgentsCommand({ args, env:
|
|
552
|
-
: `bash -lc ${shellQuote(
|
|
553
|
+
? buildWindowsAgentsCommand({ args, env: assignments.length ? env : undefined })
|
|
554
|
+
: `bash -lc ${shellQuote(assignments.concat(['agents', ...args].map(shellQuote)).join(' '))}`;
|
|
553
555
|
const sshArgs = [...SSH_OPTS, ...controlOpts()];
|
|
554
556
|
if (opts.tty)
|
|
555
557
|
sshArgs.push('-tt'); // force a PTY so the resumed agent is interactive
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a session may be resumed — the one place that answers "does this
|
|
3
|
+
* transcript belong to another machine?" and, when it does, runs the resume
|
|
4
|
+
* THERE instead of here.
|
|
5
|
+
*
|
|
6
|
+
* A session's agent state lives on the box that produced it: the harness keeps
|
|
7
|
+
* its own conversation store under that machine's home, and `machine` on a
|
|
8
|
+
* SessionMeta records where the transcript originated (see session/types.ts).
|
|
9
|
+
* A row can reach this box two ways and BOTH are remote-owned:
|
|
10
|
+
*
|
|
11
|
+
* - a **synced mirror** — locally readable at `backups/<agent>/<machine>/…`,
|
|
12
|
+
* which is exactly why the old code could not tell: the transcript file is
|
|
13
|
+
* right there, so nothing failed until the harness was asked to resume a
|
|
14
|
+
* conversation it has never seen;
|
|
15
|
+
* - a **live fan-out row** (`_remote`), whose `filePath` is on the peer's disk.
|
|
16
|
+
*
|
|
17
|
+
* Resuming either one locally starts the harness against state it does not
|
|
18
|
+
* have. Before RUSH-2022 that happened silently — `sessions-resume.ts` even fell
|
|
19
|
+
* back to `process.cwd()` when the recorded cwd did not exist locally, so a
|
|
20
|
+
* remote session resumed in whatever directory the user happened to be in.
|
|
21
|
+
*
|
|
22
|
+
* Callers act on the answer differently, and the difference is deliberate:
|
|
23
|
+
* `agents resume` and `agents sessions attach` HOP to the owner over SSH, while
|
|
24
|
+
* `resumeSessionInPlace` — the local takeover every routed caller reaches only
|
|
25
|
+
* after deciding — REFUSES. The batch `sessions resume` needs no check of its
|
|
26
|
+
* own: each of its tabs runs the canonical `agents resume <id>`
|
|
27
|
+
* (lib/session/resume-command.ts), which routes itself.
|
|
28
|
+
*/
|
|
29
|
+
import type { SessionMeta } from './types.js';
|
|
30
|
+
/**
|
|
31
|
+
* Set on the SSH hop that sends a resume to its owning device: the far side must
|
|
32
|
+
* run it, never route again.
|
|
33
|
+
*
|
|
34
|
+
* An env var rather than a flag, deliberately. The fleet is mixed-version — a
|
|
35
|
+
* peer on the released CLI would die on an unknown `--here` with
|
|
36
|
+
* `error: unknown option '--here'`, breaking the very hop this feature adds. An
|
|
37
|
+
* unrecognized exported variable is inert on every version, so routing works
|
|
38
|
+
* against old and new peers alike. The far side deletes it after reading
|
|
39
|
+
* ({@link consumeResumePinned}) so it cannot leak into the agent's own children.
|
|
40
|
+
*/
|
|
41
|
+
export declare const RESUME_PINNED_ENV = "AGENTS_RESUME_PINNED";
|
|
42
|
+
/**
|
|
43
|
+
* Whether this process was handed a resume by its owner-routing hop — read once,
|
|
44
|
+
* then cleared so a nested `agents resume` inside the running agent still routes
|
|
45
|
+
* normally.
|
|
46
|
+
*/
|
|
47
|
+
export declare function consumeResumePinned(): boolean;
|
|
48
|
+
/**
|
|
49
|
+
* The peer that owns `session`'s state, or `undefined` when this machine does.
|
|
50
|
+
*
|
|
51
|
+
* Undefined is also the answer for an untagged row (`machine` unset — a session
|
|
52
|
+
* obtained outside `discoverSessions`): there is nothing to route to, so the
|
|
53
|
+
* caller keeps the local behaviour rather than inventing a target.
|
|
54
|
+
*/
|
|
55
|
+
export declare function sessionOwnerDevice(session: Pick<SessionMeta, 'machine'>): string | undefined;
|