@skrr-ai/cli 0.1.40 → 0.1.41

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 (114) hide show
  1. package/dist/base-command.d.ts +9 -0
  2. package/dist/base-command.js +18 -3
  3. package/dist/commands/agent-shares/list-for.js +4 -11
  4. package/dist/commands/agent-shares/received.js +4 -11
  5. package/dist/commands/agent-shares/sent.js +4 -11
  6. package/dist/commands/agent-shares/with.js +3 -10
  7. package/dist/commands/agents/activity.js +1 -1
  8. package/dist/commands/agents/api-actions/add.js +6 -0
  9. package/dist/commands/agents/chat.js +6 -1
  10. package/dist/commands/agents/create.js +6 -1
  11. package/dist/commands/agents/manager/ensure.d.ts +18 -0
  12. package/dist/commands/agents/manager/ensure.js +56 -0
  13. package/dist/commands/agents/show.js +4 -0
  14. package/dist/commands/agents/tools/auth.d.ts +6 -0
  15. package/dist/commands/agents/tools/auth.js +27 -1
  16. package/dist/commands/agents/tools/index.js +11 -1
  17. package/dist/commands/agents/update.js +6 -1
  18. package/dist/commands/code/install.d.ts +4 -2
  19. package/dist/commands/code/install.js +12 -10
  20. package/dist/commands/convos/list.js +1 -3
  21. package/dist/commands/convos/show.js +4 -11
  22. package/dist/commands/daemon/index.js +3 -3
  23. package/dist/commands/daemon/install.js +4 -4
  24. package/dist/commands/daemon/login.js +1 -1
  25. package/dist/commands/daemon/logs.d.ts +3 -1
  26. package/dist/commands/daemon/logs.js +16 -1
  27. package/dist/commands/daemon/start.js +3 -3
  28. package/dist/commands/daemon/status.d.ts +9 -0
  29. package/dist/commands/daemon/status.js +22 -0
  30. package/dist/commands/dm/messages.js +1 -11
  31. package/dist/commands/followups/cancel.js +1 -1
  32. package/dist/commands/followups/resolve.js +1 -1
  33. package/dist/commands/followups/show.js +16 -0
  34. package/dist/commands/harnesses/forget.js +20 -3
  35. package/dist/commands/harnesses/install.js +18 -6
  36. package/dist/commands/harnesses/installers.d.ts +1 -1
  37. package/dist/commands/harnesses/installers.js +36 -12
  38. package/dist/commands/harnesses/list.d.ts +74 -2
  39. package/dist/commands/harnesses/list.js +231 -15
  40. package/dist/commands/harnesses/ping.d.ts +2 -0
  41. package/dist/commands/harnesses/ping.js +21 -0
  42. package/dist/commands/harnesses/show.d.ts +22 -0
  43. package/dist/commands/harnesses/show.js +116 -17
  44. package/dist/commands/harnesses/update.d.ts +8 -0
  45. package/dist/commands/harnesses/update.js +25 -1
  46. package/dist/commands/harnesses/usage.d.ts +2 -0
  47. package/dist/commands/harnesses/usage.js +7 -2
  48. package/dist/commands/instructions/status.js +13 -0
  49. package/dist/commands/instructions/update-target.d.ts +1 -0
  50. package/dist/commands/instructions/update-target.js +11 -3
  51. package/dist/commands/login.js +5 -5
  52. package/dist/commands/machines/list.js +8 -9
  53. package/dist/commands/machines/show.js +46 -17
  54. package/dist/commands/messages/show.js +3 -10
  55. package/dist/commands/shares/get.js +2 -9
  56. package/dist/commands/shares/list.js +1 -9
  57. package/dist/commands/shortcuts/show.js +4 -11
  58. package/dist/commands/skills/local.js +6 -0
  59. package/dist/commands/spaces/actions/run.js +3 -4
  60. package/dist/commands/spaces/briefs/get.js +2 -9
  61. package/dist/commands/spaces/briefs/list.js +1 -9
  62. package/dist/commands/spaces/conversations.js +1 -9
  63. package/dist/commands/spaces/heartbeats/list.js +1 -9
  64. package/dist/commands/spaces/heartbeats/status.js +2 -9
  65. package/dist/commands/tasks/activity.js +1 -10
  66. package/dist/commands/tasks/approvals/list.js +1 -9
  67. package/dist/commands/tasks/attachments/list.js +1 -9
  68. package/dist/commands/tasks/comments/list.js +1 -9
  69. package/dist/commands/tasks/dri-status.js +2 -9
  70. package/dist/commands/tasks/events/append.js +9 -7
  71. package/dist/commands/tasks/execution-timeline.js +1 -9
  72. package/dist/commands/tasks/monitor.js +1 -9
  73. package/dist/commands/tasks/result/submit.js +9 -0
  74. package/dist/commands/tasks/runs.js +1 -9
  75. package/dist/commands/tasks/show.js +5 -12
  76. package/dist/commands/token/list.d.ts +8 -0
  77. package/dist/commands/token/list.js +51 -6
  78. package/dist/help.js +8 -12
  79. package/dist/lib/agent-home-workspace.d.ts +19 -0
  80. package/dist/lib/agent-home-workspace.js +39 -0
  81. package/dist/lib/agent-resolver.js +2 -2
  82. package/dist/lib/agentic-stream.d.ts +22 -1
  83. package/dist/lib/agentic-stream.js +48 -7
  84. package/dist/lib/cli-installers.d.ts +84 -2
  85. package/dist/lib/cli-installers.js +146 -13
  86. package/dist/lib/command-miss.d.ts +22 -0
  87. package/dist/lib/command-miss.js +78 -0
  88. package/dist/lib/daemon-setup.js +2 -2
  89. package/dist/lib/daemon-target.d.ts +14 -0
  90. package/dist/lib/daemon-target.js +32 -0
  91. package/dist/lib/daemonBroker.js +1 -1
  92. package/dist/lib/daemonHandoff.js +1 -1
  93. package/dist/lib/exec-runtime-binary.js +2 -2
  94. package/dist/lib/followups.d.ts +10 -0
  95. package/dist/lib/followups.js +14 -1
  96. package/dist/lib/format.d.ts +11 -0
  97. package/dist/lib/format.js +21 -0
  98. package/dist/lib/harness-labels.d.ts +39 -0
  99. package/dist/lib/harness-labels.js +60 -0
  100. package/dist/lib/harnesses.d.ts +6 -0
  101. package/dist/lib/instruction-input.d.ts +13 -0
  102. package/dist/lib/instruction-input.js +20 -1
  103. package/dist/lib/local-skills.d.ts +17 -1
  104. package/dist/lib/local-skills.js +20 -2
  105. package/dist/lib/login.js +5 -5
  106. package/dist/lib/machines.d.ts +6 -0
  107. package/dist/lib/machines.js +68 -2
  108. package/dist/lib/runtime-tool-support.d.ts +34 -0
  109. package/dist/lib/runtime-tool-support.js +77 -0
  110. package/dist/lib/share-rows.d.ts +17 -0
  111. package/dist/lib/share-rows.js +35 -0
  112. package/dist/node_modules/@skrr-ai/data-provider/index.js +5029 -5007
  113. package/oclif.manifest.json +2689 -2584
  114. package/package.json +6 -3
@@ -51,7 +51,12 @@ function writeJsonLine(output, value) {
51
51
  * The assistant text inside a `session:output` frame's envelopes.
52
52
  *
53
53
  * Envelope shape: `{ ev: { t: 'text', text: '…' } }`. Frames whose `ev.t` is
54
- * anything else (tool calls, status) are not the answer and are skipped.
54
+ * anything else (tool calls, status) are not the answer and are skipped, and so
55
+ * is text marked `thinking: true` — the model's reasoning, which the session
56
+ * protocol carries in the same `text` event. Folded in, a Devin turn asked for
57
+ * one word printed "I need to output only the word SECOND, … single
58
+ * word.SECOND" as its answer, and `run.result.text` kept it, because the
59
+ * stream then held the final result as a suffix and the longer text won.
55
60
  */
56
61
  function envelopesText(event) {
57
62
  const envelopes = event.envelopes;
@@ -59,9 +64,11 @@ function envelopesText(event) {
59
64
  return '';
60
65
  let out = '';
61
66
  for (const envelope of envelopes) {
62
- const ev = envelope?.ev;
63
- if (ev && ev.t === 'text' && typeof ev.text === 'string')
67
+ const ev = envelope
68
+ ?.ev;
69
+ if (ev && ev.t === 'text' && ev.thinking !== true && typeof ev.text === 'string') {
64
70
  out += ev.text;
71
+ }
65
72
  }
66
73
  return out;
67
74
  }
@@ -306,6 +313,8 @@ class AgenticStreamClient {
306
313
  /** Text already written to the terminal, for suffix-only rendering. */
307
314
  renderedText = '';
308
315
  idleTimer = null;
316
+ /** Releases a connect wait still in flight; `close()` calls it. */
317
+ abandonConnectWait = null;
309
318
  reconciling = false;
310
319
  responseMessageId;
311
320
  lastEvent;
@@ -351,7 +360,13 @@ class AgenticStreamClient {
351
360
  reconnectionDelayMax: 10_000,
352
361
  });
353
362
  this.installHandlers();
354
- await this.waitForConnect();
363
+ await this.waitForConnect(this.options.attachTimeoutMs);
364
+ // A refused handshake asks the durable record, and the record can settle
365
+ // the run before any attach succeeds. `close()` then ends the wait, and
366
+ // there is nothing left to join: returning here is what lets the caller see
367
+ // the result instead of awaiting an attach nobody is still attempting.
368
+ if (this.settled || this.closed || !this.socket)
369
+ return result;
355
370
  this.socket.emit('session:join', { sessionId: this.options.sessionId });
356
371
  this.emit({ type: 'session.joined', sessionId: this.options.sessionId });
357
372
  this.armIdleProbe();
@@ -365,6 +380,14 @@ class AgenticStreamClient {
365
380
  * rather than giving up — a 200-second tool call is silence, not a failure —
366
381
  * so the cost of being wrong is one cheap read, while the cost of not asking
367
382
  * was an interactive session that never returned.
383
+ *
384
+ * The timer is deliberately NOT unref'd. It exists for the moment the socket
385
+ * stops holding the process open — a server-side disconnect, or a reconnect
386
+ * the server refuses, after which socket.io does not retry. Unref'd, it was
387
+ * the last thing pending at exactly that moment, so Node's event loop emptied
388
+ * and the CLI exited 0 with the run unsettled: `--no-stream --json` printed
389
+ * nothing at all for a turn the record said had failed (OSK-10061). A run
390
+ * that has not settled is a reason to stay alive; `close()` clears the timer.
368
391
  */
369
392
  armIdleProbe() {
370
393
  if (!this.options.reconcileTerminal)
@@ -374,7 +397,6 @@ class AgenticStreamClient {
374
397
  if (this.idleTimer)
375
398
  clearTimeout(this.idleTimer);
376
399
  this.idleTimer = setTimeout(() => void this.probeTerminal('idle'), this.options.idleTerminalProbeMs ?? 20_000);
377
- this.idleTimer.unref?.();
378
400
  }
379
401
  /**
380
402
  * Settle from the durable record, if it has reached a terminal state.
@@ -438,6 +460,7 @@ class AgenticStreamClient {
438
460
  clearTimeout(this.idleTimer);
439
461
  this.idleTimer = null;
440
462
  }
463
+ this.abandonConnectWait?.();
441
464
  const socket = this.socket;
442
465
  this.socket = null;
443
466
  // Stop dispatch BEFORE tearing anything down. socket.io can deliver a
@@ -838,6 +861,11 @@ class AgenticStreamClient {
838
861
  if (finalText && finalText !== this.accumulated && !accumulationIsSuperset) {
839
862
  this.accumulated = finalText;
840
863
  }
864
+ // The finished answer, as every other surface shows it: the web applies
865
+ // this same sanitizer, so a scheduling JSON block or leaked function-call
866
+ // XML the model wrote into its reply is removed here too instead of being
867
+ // printed as part of the agent's answer.
868
+ this.accumulated = (0, data_provider_1.sanitizeFinalText)(this.accumulated);
841
869
  if (!this.options.quiet && !this.options.jsonl && this.accumulated) {
842
870
  const output = this.options.output ?? process.stdout;
843
871
  const { append, shown } = renderableDelta(this.renderedText, this.accumulated);
@@ -872,6 +900,10 @@ class AgenticStreamClient {
872
900
  * Now errors are surfaced as events and retried until a bounded deadline, so
873
901
  * only a sustained inability to attach gives up — and the caller turns that
874
902
  * into "accepted but detached", never "failed".
903
+ *
904
+ * The deadline and retry timers hold the process open (see `armDeadline`), so
905
+ * `close()` is what releases them — and it ends the wait too, whether the
906
+ * caller gave up or the durable record settled the run first (OSK-10061).
875
907
  */
876
908
  waitForConnect(timeoutMs = 30_000) {
877
909
  if (this.socket?.connected)
@@ -889,7 +921,12 @@ class AgenticStreamClient {
889
921
  reject(Object.assign(new Error(`Could not attach to the run stream within ${Math.round(ms / 1000)}s` +
890
922
  `${lastError ? `: ${lastError.message}` : ''}`), { code: 'STREAM_ATTACH_TIMEOUT', lastError }));
891
923
  }, ms);
892
- handle.unref?.();
924
+ // Deliberately NOT unref'd, nor is the retry timer below. A connect
925
+ // refused by middleware leaves no live socket, so these timers are the
926
+ // only thing keeping the process alive while it waits. Unref'd, Node's
927
+ // event loop drained and `agents chat` exited 0 right after printing
928
+ // "Retrying in 30s" — before the retry, without the answer the run had
929
+ // produced, and without the detach notice a script needs.
893
930
  return handle;
894
931
  }
895
932
  const onConnect = () => {
@@ -933,7 +970,6 @@ class AgenticStreamClient {
933
970
  // must not reject here and turn a pause into a failure.
934
971
  }
935
972
  }, jitteredRetryDelay(refusal.retryAfterMs));
936
- retryTimer.unref?.();
937
973
  };
938
974
  const cleanup = () => {
939
975
  clearTimeout(timer);
@@ -941,6 +977,11 @@ class AgenticStreamClient {
941
977
  clearTimeout(retryTimer);
942
978
  socket.off('connect', onConnect);
943
979
  socket.off('connect_error', onError);
980
+ this.abandonConnectWait = null;
981
+ };
982
+ this.abandonConnectWait = () => {
983
+ cleanup();
984
+ resolve();
944
985
  };
945
986
  socket.once('connect', onConnect);
946
987
  socket.on('connect_error', onError);
@@ -44,7 +44,7 @@ export interface CliInstallersResponse {
44
44
  * Providers the platform knows about that this daemon did not offer.
45
45
  *
46
46
  * The inventory is whatever the CONNECTED daemon supports, and an older daemon
47
- * legitimately supports fewer: `devin` arrived in daemon 0.8.43, so a machine
47
+ * legitimately supports fewer: `devin` arrived in the 0.8.45 daemon release, so a machine
48
48
  * running 0.8.35 answers with six providers instead of seven. Observed here —
49
49
  * the desktop app restarted the daemon from its bundled 0.8.35 copy and Devin
50
50
  * disappeared from the install list between two page loads, with nothing on
@@ -56,8 +56,28 @@ export interface CliInstallersResponse {
56
56
  * is already on this side of the wire. Naming the gap is what turns "Devin is
57
57
  * gone" into "this daemon is older than Devin".
58
58
  */
59
+ /**
60
+ * The first STABLE daemon release (`daemon-v*` tag) whose
61
+ * MANAGED_CLI_PROVIDER_IDS included each provider. Release tags, not
62
+ * package.json at the commit that added it: the version is bumped before a
63
+ * release is cut, so the commit that added devin already read 0.8.43 while the
64
+ * 0.8.43 release did not contain it — this table said "added in 0.8.43" to a
65
+ * 0.8.43 user (OSK-9537 verifier). Derived with, per provider:
66
+ * for t in $(git tag -l 'daemon-v*' | sort -V); do
67
+ * git show "$t:daemon/src/cli-installers.ts" | grep -q "'devin'" && echo "$t" && break
68
+ * done
69
+ * Providers present since managed installers first shipped (0.8.1) are omitted.
70
+ */
71
+ export declare const MANAGED_CLI_PROVIDER_SINCE: Readonly<Record<string, string>>;
72
+ /**
73
+ * "Not offered" with the release that added each provider, so a reader learns
74
+ * WHICH update fixes it rather than only that one might (OSK-9537).
75
+ */
76
+ export declare function describeProvidersNotOffered(missing: readonly string[]): string;
59
77
  export declare function providersNotOffered(offered: readonly ManagedCliProviderState[] | undefined, known: readonly string[]): string[];
60
- export declare function listCliInstallers(daemonId: string, credential?: unknown): Promise<CliInstallersResponse>;
78
+ export declare function listCliInstallers(daemonId: string, credential?: unknown, opts?: {
79
+ signal?: AbortSignal;
80
+ }): Promise<CliInstallersResponse>;
61
81
  /**
62
82
  * The one-word readiness verdict for a provider, and whether it is good news.
63
83
  *
@@ -74,10 +94,63 @@ export declare function readinessOf(provider: ManagedCliProviderState): {
74
94
  ok: boolean;
75
95
  };
76
96
  /** A job the daemon runs to install one managed provider. */
97
+ /**
98
+ * Readiness per provider for one daemon, keyed by CANONICAL provider — or null
99
+ * when the daemon could not be asked (disconnected, older, refused).
100
+ *
101
+ * The harness row's `authStatus` is the DAEMON's standing with skrr, written
102
+ * across every row of a daemon at once, so `harnesses show`, `machines show`
103
+ * and `ping` answered "ok" per provider for a Devin its own CLI called "Not
104
+ * logged in" (OSK-9263, OSK-9265). This is the per-provider answer those
105
+ * surfaces print instead. Best-effort by design: a failure here must never
106
+ * fail the command that asked.
107
+ */
108
+ export declare function readinessByProvider(daemonId: string, credential?: unknown, opts?: {
109
+ signal?: AbortSignal;
110
+ }): Promise<Map<string, ReturnType<typeof readinessOf>> | null>;
111
+ /** The canonical provider a harness row names, for looking it up in that map. */
112
+ export declare function readinessKey(provider: string | null | undefined): string;
113
+ export type ProviderReadiness = ReturnType<typeof readinessOf>;
114
+ /** The fields of a harness row that decide whether, and whom, to ask. */
115
+ export interface ReadinessRow {
116
+ daemonId?: string | null;
117
+ provider?: string | null;
118
+ status?: string | null;
119
+ superseded?: boolean;
120
+ }
121
+ /** How long one daemon may take to answer before a list prints `-` for it. */
122
+ export declare const ROW_READINESS_TIMEOUT_MS = 5000;
123
+ /**
124
+ * Readiness for many harness rows at once: ONE installer read per connected
125
+ * daemon, all in parallel, however many rows each daemon has.
126
+ *
127
+ * Only a row whose daemon can answer is asked for — online and not superseded;
128
+ * an offline or replaced registration has nobody on the other end. Each read is
129
+ * bounded, because a list must not hang on one slow machine; a daemon that does
130
+ * not answer in time reads as unknown (`undefined`), never as `ready`.
131
+ *
132
+ * `asked` and `answered` let a caller tell "this daemon does not report it"
133
+ * from "nothing could be asked", which call for different next steps.
134
+ */
135
+ export declare function readinessForRows(rows: readonly ReadinessRow[], credential?: unknown, opts?: {
136
+ timeoutMs?: number;
137
+ }): Promise<{
138
+ lookup: (row: ReadinessRow) => ProviderReadiness | undefined;
139
+ /** Whether this row's daemon answered at all — so a `-` can say why. */
140
+ daemonAnswered: (row: ReadinessRow) => boolean;
141
+ asked: number;
142
+ answered: number;
143
+ }>;
77
144
  export interface ManagedCliInstallJob {
78
145
  id: string;
79
146
  status: 'queued' | 'running' | 'succeeded' | 'failed';
80
147
  error?: string;
148
+ /**
149
+ * The daemon's job log: the LAST lines only (a sliding window, 200 lines in
150
+ * `daemon/src/cli-installers.ts`). This is the field the daemon sends.
151
+ */
152
+ logTail?: string[];
153
+ /** Never sent by any daemon; kept so an older caller's shape still types. */
81
154
  log?: string[];
82
155
  }
83
156
  export interface StartInstallResponse {
@@ -92,6 +165,15 @@ export declare function startManagedCliInstall(daemonId: string, provider: strin
92
165
  export declare function readManagedCliInstallJob(daemonId: string, jobId: string, credential?: unknown): Promise<{
93
166
  job?: ManagedCliInstallJob;
94
167
  }>;
168
+ /**
169
+ * The lines of a job's log tail not yet printed.
170
+ *
171
+ * The tail is a SLIDING window: once an installer writes more than the daemon
172
+ * keeps, earlier lines fall off the front, so an index into it skips or repeats
173
+ * lines. Instead, find the longest run at the start of the new tail that matches
174
+ * the end of what was already printed, and print only what follows it.
175
+ */
176
+ export declare function unseenLogLines(printed: readonly string[], tail: readonly string[]): string[];
95
177
  /** A 100 MB download over a hotel connection is the case this has to survive. */
96
178
  export declare const INSTALL_MAX_WAIT_MS: number;
97
179
  export declare const INSTALL_POLL_INTERVAL_MS = 2000;
@@ -1,11 +1,16 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.INSTALL_POLL_INTERVAL_MS = exports.INSTALL_MAX_WAIT_MS = void 0;
3
+ exports.INSTALL_POLL_INTERVAL_MS = exports.INSTALL_MAX_WAIT_MS = exports.ROW_READINESS_TIMEOUT_MS = exports.MANAGED_CLI_PROVIDER_SINCE = void 0;
4
+ exports.describeProvidersNotOffered = describeProvidersNotOffered;
4
5
  exports.providersNotOffered = providersNotOffered;
5
6
  exports.listCliInstallers = listCliInstallers;
6
7
  exports.readinessOf = readinessOf;
8
+ exports.readinessByProvider = readinessByProvider;
9
+ exports.readinessKey = readinessKey;
10
+ exports.readinessForRows = readinessForRows;
7
11
  exports.startManagedCliInstall = startManagedCliInstall;
8
12
  exports.readManagedCliInstallJob = readManagedCliInstallJob;
13
+ exports.unseenLogLines = unseenLogLines;
9
14
  exports.followManagedCliInstallJob = followManagedCliInstallJob;
10
15
  /**
11
16
  * The daemon's managed-CLI installer inventory, as the CLI reads it.
@@ -21,19 +26,24 @@ exports.followManagedCliInstallJob = followManagedCliInstallJob;
21
26
  * The CLI called the route from exactly one place (`commands/code/install.ts`),
22
27
  * for one provider, to read one field, and showed the user none of it. So from
23
28
  * the CLI there was no way to learn that a harness listed as `online` had no
24
- * binary at all — which is the state this account's `skrr-code` row was in.
29
+ * binary at all — which is the state this account's first-party harness row was in.
25
30
  *
26
31
  * The shape lives here rather than beside a command because two commands read
27
32
  * it now. Root `CLAUDE.md`: a second declaration is how a surface silently
28
33
  * drifts from the server that produced it.
29
34
  */
35
+ const auth_core_1 = require("@skrr-ai/auth-core");
30
36
  const data_provider_1 = require("@skrr-ai/data-provider");
31
37
  const api_fetch_1 = require("./api-fetch");
38
+ /** A provider in its canonical spelling, or '' for a missing one. */
39
+ function canonical(value) {
40
+ return typeof value === 'string' ? String((0, data_provider_1.canonicalHarnessProvider)(value) ?? value) : '';
41
+ }
32
42
  /**
33
43
  * Providers the platform knows about that this daemon did not offer.
34
44
  *
35
45
  * The inventory is whatever the CONNECTED daemon supports, and an older daemon
36
- * legitimately supports fewer: `devin` arrived in daemon 0.8.43, so a machine
46
+ * legitimately supports fewer: `devin` arrived in the 0.8.45 daemon release, so a machine
37
47
  * running 0.8.35 answers with six providers instead of seven. Observed here —
38
48
  * the desktop app restarted the daemon from its bundled 0.8.35 copy and Devin
39
49
  * disappeared from the install list between two page loads, with nothing on
@@ -45,19 +55,47 @@ const api_fetch_1 = require("./api-fetch");
45
55
  * is already on this side of the wire. Naming the gap is what turns "Devin is
46
56
  * gone" into "this daemon is older than Devin".
47
57
  */
58
+ /**
59
+ * The first STABLE daemon release (`daemon-v*` tag) whose
60
+ * MANAGED_CLI_PROVIDER_IDS included each provider. Release tags, not
61
+ * package.json at the commit that added it: the version is bumped before a
62
+ * release is cut, so the commit that added devin already read 0.8.43 while the
63
+ * 0.8.43 release did not contain it — this table said "added in 0.8.43" to a
64
+ * 0.8.43 user (OSK-9537 verifier). Derived with, per provider:
65
+ * for t in $(git tag -l 'daemon-v*' | sort -V); do
66
+ * git show "$t:daemon/src/cli-installers.ts" | grep -q "'devin'" && echo "$t" && break
67
+ * done
68
+ * Providers present since managed installers first shipped (0.8.1) are omitted.
69
+ */
70
+ exports.MANAGED_CLI_PROVIDER_SINCE = Object.freeze({
71
+ // First offered under its older spelling; the canonical key covers both.
72
+ [auth_core_1.FIRST_PARTY_HARNESS.provider]: '0.8.2',
73
+ devin: '0.8.45',
74
+ });
75
+ /**
76
+ * "Not offered" with the release that added each provider, so a reader learns
77
+ * WHICH update fixes it rather than only that one might (OSK-9537).
78
+ */
79
+ function describeProvidersNotOffered(missing) {
80
+ return missing
81
+ .map((provider) => {
82
+ const since = exports.MANAGED_CLI_PROVIDER_SINCE[canonical(provider)];
83
+ return since ? `${provider} (added in daemon ${since})` : provider;
84
+ })
85
+ .join(', ');
86
+ }
48
87
  function providersNotOffered(offered, known) {
49
88
  // Canonicalize BOTH sides. An older daemon answers under the spelling it was
50
- // built with — the 0.8.35 build reports the first-party harness as
51
- // `sky-code`, so a raw comparison against `skrr-code` reported it missing on
89
+ // built with — the 0.8.35 build reports the first-party harness under its
90
+ // older spelling, so a raw comparison against the current one reported it missing on
52
91
  // a machine where it is installed and working. Caught by running this against
53
92
  // the very daemon the finding was about: the first version of this helper
54
- // said "Not offered: devin, skrr-code" when only `devin` was true.
55
- const canonical = (value) => typeof value === 'string' ? String((0, data_provider_1.canonicalHarnessProvider)(value) ?? value) : '';
93
+ // said "Not offered: devin, <first-party harness>" when only `devin` was true.
56
94
  const seen = new Set((offered ?? []).map((provider) => canonical(provider.provider)).filter(Boolean));
57
95
  return known.filter((provider) => !seen.has(canonical(provider)));
58
96
  }
59
- async function listCliInstallers(daemonId, credential) {
60
- return (0, api_fetch_1.apiFetch)(`/api/daemons/${encodeURIComponent(daemonId)}/cli-installers`, { credential: credential });
97
+ async function listCliInstallers(daemonId, credential, opts = {}) {
98
+ return (0, api_fetch_1.apiFetch)(`/api/daemons/${encodeURIComponent(daemonId)}/cli-installers`, { credential: credential, ...(opts.signal ? { signal: opts.signal } : {}) });
61
99
  }
62
100
  /**
63
101
  * The one-word readiness verdict for a provider, and whether it is good news.
@@ -95,18 +133,110 @@ function readinessOf(provider) {
95
133
  if (!auth) {
96
134
  return {
97
135
  label: 'unknown',
98
- detail: 'Installed. This daemon cannot read this provider’s credential state.',
136
+ detail: (0, data_provider_1.managedCliReadinessUnknownMessage)(),
99
137
  ok: false,
100
138
  };
101
139
  }
102
140
  return { label: auth.status, detail: auth.message, ok: auth.status === 'ready' };
103
141
  }
142
+ /** A job the daemon runs to install one managed provider. */
143
+ /**
144
+ * Readiness per provider for one daemon, keyed by CANONICAL provider — or null
145
+ * when the daemon could not be asked (disconnected, older, refused).
146
+ *
147
+ * The harness row's `authStatus` is the DAEMON's standing with skrr, written
148
+ * across every row of a daemon at once, so `harnesses show`, `machines show`
149
+ * and `ping` answered "ok" per provider for a Devin its own CLI called "Not
150
+ * logged in" (OSK-9263, OSK-9265). This is the per-provider answer those
151
+ * surfaces print instead. Best-effort by design: a failure here must never
152
+ * fail the command that asked.
153
+ */
154
+ async function readinessByProvider(daemonId, credential, opts = {}) {
155
+ let response;
156
+ try {
157
+ response = await listCliInstallers(daemonId, credential, opts);
158
+ }
159
+ catch {
160
+ return null;
161
+ }
162
+ const out = new Map();
163
+ for (const provider of response.providers ?? []) {
164
+ const key = canonical(provider.provider);
165
+ if (key)
166
+ out.set(key, readinessOf(provider));
167
+ }
168
+ return out;
169
+ }
170
+ /** The canonical provider a harness row names, for looking it up in that map. */
171
+ function readinessKey(provider) {
172
+ return canonical(provider);
173
+ }
174
+ /** How long one daemon may take to answer before a list prints `-` for it. */
175
+ exports.ROW_READINESS_TIMEOUT_MS = 5_000;
176
+ /**
177
+ * Readiness for many harness rows at once: ONE installer read per connected
178
+ * daemon, all in parallel, however many rows each daemon has.
179
+ *
180
+ * Only a row whose daemon can answer is asked for — online and not superseded;
181
+ * an offline or replaced registration has nobody on the other end. Each read is
182
+ * bounded, because a list must not hang on one slow machine; a daemon that does
183
+ * not answer in time reads as unknown (`undefined`), never as `ready`.
184
+ *
185
+ * `asked` and `answered` let a caller tell "this daemon does not report it"
186
+ * from "nothing could be asked", which call for different next steps.
187
+ */
188
+ async function readinessForRows(rows, credential, opts = {}) {
189
+ const daemonIds = [
190
+ ...new Set(rows
191
+ .filter((row) => row.status === 'online' && !row.superseded && row.daemonId)
192
+ .map((row) => row.daemonId)),
193
+ ];
194
+ const timeoutMs = opts.timeoutMs ?? exports.ROW_READINESS_TIMEOUT_MS;
195
+ const answers = await Promise.all(daemonIds.map(async (daemonId) => [
196
+ daemonId,
197
+ await readinessByProvider(daemonId, credential, {
198
+ signal: AbortSignal.timeout(timeoutMs),
199
+ }),
200
+ ]));
201
+ const byDaemon = new Map(answers);
202
+ return {
203
+ lookup: (row) => row.status === 'online' && !row.superseded && row.daemonId
204
+ ? byDaemon.get(row.daemonId)?.get(readinessKey(row.provider))
205
+ : undefined,
206
+ daemonAnswered: (row) => Boolean(row.daemonId && byDaemon.get(row.daemonId)),
207
+ asked: daemonIds.length,
208
+ answered: answers.filter(([, answer]) => answer !== null).length,
209
+ };
210
+ }
104
211
  async function startManagedCliInstall(daemonId, provider, credential) {
105
212
  return (0, api_fetch_1.apiFetch)(`/api/daemons/${encodeURIComponent(daemonId)}/cli-installers/${encodeURIComponent(provider)}/install`, { method: 'POST', body: {}, credential: credential });
106
213
  }
107
214
  async function readManagedCliInstallJob(daemonId, jobId, credential) {
108
215
  return (0, api_fetch_1.apiFetch)(`/api/daemons/${encodeURIComponent(daemonId)}/cli-installers/jobs/${encodeURIComponent(jobId)}`, { credential: credential });
109
216
  }
217
+ const MAX_TRACKED_LOG_LINES = 400;
218
+ /**
219
+ * The lines of a job's log tail not yet printed.
220
+ *
221
+ * The tail is a SLIDING window: once an installer writes more than the daemon
222
+ * keeps, earlier lines fall off the front, so an index into it skips or repeats
223
+ * lines. Instead, find the longest run at the start of the new tail that matches
224
+ * the end of what was already printed, and print only what follows it.
225
+ */
226
+ function unseenLogLines(printed, tail) {
227
+ for (let overlap = Math.min(printed.length, tail.length); overlap > 0; overlap -= 1) {
228
+ let matches = true;
229
+ for (let i = 0; i < overlap; i += 1) {
230
+ if (printed[printed.length - overlap + i] !== tail[i]) {
231
+ matches = false;
232
+ break;
233
+ }
234
+ }
235
+ if (matches)
236
+ return tail.slice(overlap);
237
+ }
238
+ return [...tail];
239
+ }
110
240
  /** A 100 MB download over a hotel connection is the case this has to survive. */
111
241
  exports.INSTALL_MAX_WAIT_MS = 10 * 60_000;
112
242
  exports.INSTALL_POLL_INTERVAL_MS = 2_000;
@@ -125,16 +255,19 @@ exports.INSTALL_POLL_INTERVAL_MS = 2_000;
125
255
  */
126
256
  async function followManagedCliInstallJob(input) {
127
257
  const deadline = Date.now() + (input.maxWaitMs ?? exports.INSTALL_MAX_WAIT_MS);
128
- let printed = 0;
258
+ let printed = [];
129
259
  for (;;) {
130
260
  const res = await readManagedCliInstallJob(input.daemonId, input.jobId, input.credential);
131
261
  const job = res.job;
132
262
  if (!job)
133
263
  return { kind: 'missing', jobId: input.jobId };
134
264
  if (input.onLogLine) {
135
- for (const line of (job.log ?? []).slice(printed))
265
+ // `logTail`, the field the daemon sends — this read `log`, which no daemon
266
+ // has ever sent, so every install printed nothing until it ended (OSK-10103).
267
+ const fresh = unseenLogLines(printed, job.logTail ?? job.log ?? []);
268
+ for (const line of fresh)
136
269
  input.onLogLine(line);
137
- printed = (job.log ?? []).length;
270
+ printed = [...printed, ...fresh].slice(-MAX_TRACKED_LOG_LINES);
138
271
  }
139
272
  if (job.status === 'succeeded' || job.status === 'failed')
140
273
  return { kind: 'settled', job };
@@ -23,6 +23,8 @@ export type CommandLookup = {
23
23
  bin: string;
24
24
  findCommand: (id: string) => {
25
25
  flags?: Record<string, unknown>;
26
+ args?: Record<string, unknown>;
27
+ strict?: boolean;
26
28
  } | undefined;
27
29
  commandIDs: string[];
28
30
  topicNames: string[];
@@ -34,6 +36,26 @@ export declare function editDistance(a: string, b: string): number;
34
36
  * only a verdict.
35
37
  */
36
38
  export declare function nearestCommands(id: string, all: string[], limit?: number): string[];
39
+ /**
40
+ * The command id a `--help` request is about, resolved the way oclif resolves
41
+ * the same words when they are RUN.
42
+ *
43
+ * The help override used to join every leading word into the id, so
44
+ * `skrr harnesses install devin --help` asked for `harnesses:install:devin`,
45
+ * found no such command, and reported that `harnesses install` "does not take
46
+ * the positional argument `devin`" — the positional its own help lists
47
+ * (OSK-10099). Running the same words works, because oclif stops collecting id
48
+ * words at the first resolved command that takes arguments: from there on a
49
+ * word is an argument, not part of the name. Adding `--help` to a command you
50
+ * are about to run is the commonest way to check it, so the two must agree.
51
+ *
52
+ * Mirrors `collateSpacedCmdIDFromArgs` in `@oclif/core/lib/help/util` (not a
53
+ * public export): a word extends the id while the longer id is a command or a
54
+ * prefix of one; otherwise it is appended only when the id so far is not a
55
+ * command that accepts arguments, so a genuinely wrong word under a command
56
+ * that takes none (`daemon reload`) still reaches the miss message.
57
+ */
58
+ export declare function helpSubjectId(tokens: readonly string[], config: CommandLookup): string;
37
59
  /**
38
60
  * The message for a colon-joined id that did not resolve.
39
61
  *
@@ -22,10 +22,44 @@
22
22
  Object.defineProperty(exports, "__esModule", { value: true });
23
23
  exports.editDistance = editDistance;
24
24
  exports.nearestCommands = nearestCommands;
25
+ exports.helpSubjectId = helpSubjectId;
25
26
  exports.describeCommandMiss = describeCommandMiss;
26
27
  exports.lookupFromConfig = lookupFromConfig;
27
28
  /** A uuid-ish token, which is what a stray space id looks like. */
28
29
  const LOOKS_LIKE_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
30
+ const DAEMON_READINESS = [
31
+ {
32
+ id: 'harnesses:installers',
33
+ usage: 'harnesses installers --daemon <id>',
34
+ answers: 'whether each coding-agent CLI on that machine is installed, and signed in where the daemon can tell',
35
+ },
36
+ {
37
+ id: 'daemon:status',
38
+ usage: 'daemon status',
39
+ answers: 'whether the daemon itself is running and connected',
40
+ },
41
+ ];
42
+ const ANSWERED_ELSEWHERE = {
43
+ daemon: {
44
+ health: DAEMON_READINESS,
45
+ readiness: DAEMON_READINESS,
46
+ ready: DAEMON_READINESS,
47
+ doctor: DAEMON_READINESS,
48
+ check: DAEMON_READINESS,
49
+ },
50
+ };
51
+ // `skrr daemons <sub>` is the same topic.
52
+ const ANSWERED_ELSEWHERE_BY_COMMAND = { ...ANSWERED_ELSEWHERE, daemons: ANSWERED_ELSEWHERE.daemon };
53
+ function describeAnsweredElsewhere(config, resolved, extras) {
54
+ if (extras.length !== 1)
55
+ return null;
56
+ const targets = (ANSWERED_ELSEWHERE_BY_COMMAND[resolved]?.[extras[0].toLowerCase()] ?? []).filter((target) => config.findCommand(target.id) !== undefined);
57
+ if (targets.length === 0)
58
+ return null;
59
+ const spoken = `${config.bin} ${resolved.split(':').join(' ')}`;
60
+ return (`\`${spoken}\` has no \`${extras[0]}\`. Depending on what you want to know:\n` +
61
+ targets.map((t) => ` \`${config.bin} ${t.usage}\` — ${t.answers}`).join('\n'));
62
+ }
29
63
  /** Cheap edit distance, bounded — this runs only on a miss. */
30
64
  function editDistance(a, b) {
31
65
  const rows = a.length + 1;
@@ -60,6 +94,47 @@ function nearestCommands(id, all, limit = 3) {
60
94
  .slice(0, limit)
61
95
  .map(({ candidate }) => candidate));
62
96
  }
97
+ /**
98
+ * The command id a `--help` request is about, resolved the way oclif resolves
99
+ * the same words when they are RUN.
100
+ *
101
+ * The help override used to join every leading word into the id, so
102
+ * `skrr harnesses install devin --help` asked for `harnesses:install:devin`,
103
+ * found no such command, and reported that `harnesses install` "does not take
104
+ * the positional argument `devin`" — the positional its own help lists
105
+ * (OSK-10099). Running the same words works, because oclif stops collecting id
106
+ * words at the first resolved command that takes arguments: from there on a
107
+ * word is an argument, not part of the name. Adding `--help` to a command you
108
+ * are about to run is the commonest way to check it, so the two must agree.
109
+ *
110
+ * Mirrors `collateSpacedCmdIDFromArgs` in `@oclif/core/lib/help/util` (not a
111
+ * public export): a word extends the id while the longer id is a command or a
112
+ * prefix of one; otherwise it is appended only when the id so far is not a
113
+ * command that accepts arguments, so a genuinely wrong word under a command
114
+ * that takes none (`daemon reload`) still reaches the miss message.
115
+ */
116
+ function helpSubjectId(tokens, config) {
117
+ const usable = new Set();
118
+ for (const id of [...config.commandIDs, ...config.topicNames]) {
119
+ const parts = id.split(':');
120
+ for (let i = 1; i <= parts.length; i += 1)
121
+ usable.add(parts.slice(0, i).join(':'));
122
+ }
123
+ const taken = [];
124
+ for (const token of tokens) {
125
+ if (token.startsWith('-') || token.includes('='))
126
+ break;
127
+ if (usable.has([...taken, token].join(':'))) {
128
+ taken.push(token);
129
+ continue;
130
+ }
131
+ const current = taken.length > 0 ? config.findCommand(taken.join(':')) : undefined;
132
+ if (current && (current.strict === false || Object.keys(current.args ?? {}).length > 0))
133
+ break;
134
+ taken.push(token);
135
+ }
136
+ return taken.join(':');
137
+ }
63
138
  /**
64
139
  * The message for a colon-joined id that did not resolve.
65
140
  *
@@ -81,6 +156,9 @@ function describeCommandMiss(config, id) {
81
156
  if (!found)
82
157
  continue;
83
158
  const extras = parts.slice(take);
159
+ const redirect = describeAnsweredElsewhere(config, parts.slice(0, take).join(':'), extras);
160
+ if (redirect)
161
+ return redirect;
84
162
  const spoken = `${config.bin} ${parts.slice(0, take).join(' ')}`;
85
163
  // Suggest the flag only when the command actually has it, so the hint can
86
164
  // never point at something that does not exist.
@@ -62,7 +62,7 @@ async function offerDaemonSetup(opts = {}) {
62
62
  }
63
63
  const confirmImpl = opts.confirmImpl ?? prompt_1.confirm;
64
64
  log('');
65
- log(' This machine has no skrr runtime. It is what lets your agents work here —');
65
+ log(' This computer has no skrr background service. It is what lets your agents work here —');
66
66
  log(' running commands, editing files, and driving a browser on this machine.');
67
67
  // This creates a persistent service with broad local capabilities, so a
68
68
  // bare Enter must visibly and behaviorally decline it. `confirm` names its
@@ -100,7 +100,7 @@ async function offerDaemonSetup(opts = {}) {
100
100
  if (err.message === 'runtime-binary-not-installed') {
101
101
  return {
102
102
  status: 'failed',
103
- detail: 'the runtime vanished between fetching and installing it',
103
+ detail: 'the background service vanished between fetching and installing it',
104
104
  };
105
105
  }
106
106
  return { status: 'failed', detail: err.message };
@@ -99,5 +99,19 @@ export interface ResolveDaemonTargetInput {
99
99
  listActiveDaemons: () => Promise<ActiveDaemonRow[]>;
100
100
  }
101
101
  export declare function resolveDaemonTarget(input: ResolveDaemonTargetInput): Promise<DaemonTarget>;
102
+ /**
103
+ * Daemons that are CONNECTED right now, under the id the daemon-control API
104
+ * takes (`daemon_…`, `dedicated-runtime-…`).
105
+ *
106
+ * `listActiveDaemons` returns Daemon RESOURCE rows: "active" means not revoked,
107
+ * and `id` is the resource's own id. The installer routes relay an RPC to a
108
+ * connected daemon by its connection id, so that list could not be used to pick
109
+ * one: its refusal counted 50 "active daemons" — most of them sandboxes gone for
110
+ * weeks — and suggested values like `d_69eb2d1e...` that `--daemon` then failed
111
+ * to find, and a single "active" daemon would have been auto-picked by an id no
112
+ * route accepts (OSK-9993). Online harness rows carry exactly the ids and the
113
+ * liveness needed, one row per (daemon, provider).
114
+ */
115
+ export declare function listConnectedDaemons(credential: ResolvedCredential): Promise<ActiveDaemonRow[]>;
102
116
  /** The same list `skrr list-daemons` prints, so a refusal can name values that work. */
103
117
  export declare function listActiveDaemons(credential: ResolvedCredential): Promise<ActiveDaemonRow[]>;