@skrr-ai/cli 0.1.41 → 0.1.42

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.
@@ -4,7 +4,6 @@ const core_1 = require("@oclif/core");
4
4
  const auth_core_1 = require("@skrr-ai/auth-core");
5
5
  const base_command_1 = require("../../base-command");
6
6
  const api_fetch_1 = require("../../lib/api-fetch");
7
- const daemon_ref_1 = require("../../lib/daemon-ref");
8
7
  const daemon_target_1 = require("../../lib/daemon-target");
9
8
  const first_party_harness_1 = require("../../lib/first-party-harness");
10
9
  /**
@@ -84,11 +83,11 @@ class CodeInstall extends base_command_1.BaseCommand {
84
83
  // survive in mine.
85
84
  const reason = await this.installerNote(daemonId);
86
85
  if (reason) {
87
- this.log(`Nothing to do on ${(0, daemon_ref_1.formatDaemonRef)(daemonId)} — ${reason}`);
86
+ this.log(`Nothing to do on ${daemonId} — ${reason}`);
88
87
  return;
89
88
  }
90
89
  this.failWithCliError({
91
- message: `The daemon on ${(0, daemon_ref_1.formatDaemonRef)(daemonId)} accepted the request and returned no ` +
90
+ message: `The daemon on ${daemonId} accepted the request and returned no ` +
92
91
  'install job, and does not say why. Nothing has been shown to start. ' +
93
92
  `Check what it offers with \`${this.config.bin} harnesses installers --daemon ${daemonId}\`, and ` +
94
93
  `\`${this.config.bin} code doctor\` here once you expect it to have landed.`,
@@ -104,7 +103,7 @@ class CodeInstall extends base_command_1.BaseCommand {
104
103
  this.log(JSON.stringify({ daemonId, ...started }, null, 2));
105
104
  return;
106
105
  }
107
- this.log(`Install started on ${(0, daemon_ref_1.formatDaemonRef)(daemonId)} — job ${jobId}.`);
106
+ this.log(`Install started on ${daemonId} — job ${jobId}.`);
108
107
  return;
109
108
  }
110
109
  const job = await this.followJob(daemonId, jobId, flags.json === true);
@@ -95,6 +95,10 @@ class FollowupsRemind extends base_command_1.BaseCommand {
95
95
  return;
96
96
  }
97
97
  const row = response.followup;
98
+ if (response.reused && response.rescheduled) {
99
+ this.log(`Moved: ${row?.id} now fires at ${row?.nextRunAt ?? at} — the same reminder already existed, so it was rescheduled rather than duplicated.`);
100
+ return;
101
+ }
98
102
  if (response.reused) {
99
103
  this.log(`Already armed: ${row?.id} — the same reminder exists and was not duplicated.`);
100
104
  return;
@@ -1,4 +1,5 @@
1
1
  import { BaseCommand } from '../../base-command';
2
+ import { type ManagedCliProviderState } from '../../lib/cli-installers';
2
3
  /**
3
4
  * Install one managed coding-agent CLI on a machine.
4
5
  *
@@ -33,9 +34,26 @@ export default class HarnessesInstall extends BaseCommand {
33
34
  static flags: {
34
35
  json: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
35
36
  daemon: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
37
+ update: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
36
38
  };
37
39
  run(): Promise<void>;
40
+ /**
41
+ * Update an installed CLI in place (OSK-10096).
42
+ *
43
+ * Asks the inventory first rather than posting blind: a daemon older than
44
+ * managed updates has no handler for the request and would leave it to time
45
+ * out, and a CLI that updates some other way (the copy inside Devin.app) has a
46
+ * reason the daemon already words. Both are answered before anything runs.
47
+ */
48
+ private update;
49
+ /** Follow a job to its end, or report why it could not be followed (and return nothing). */
50
+ private follow;
38
51
  /** What the daemon says about this provider right now, best effort. */
39
52
  private installerNote;
40
53
  private daemonIdFor;
41
54
  }
55
+ /**
56
+ * The next step after "Already installed.": how to update it, or where its
57
+ * update comes from — or nothing from a daemon that cannot say (OSK-10096).
58
+ */
59
+ export declare function updateHint(provider: string, state: ManagedCliProviderState | undefined, bin: string): string | null;
@@ -1,10 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.updateHint = updateHint;
3
4
  const core_1 = require("@oclif/core");
4
5
  const data_provider_1 = require("@skrr-ai/data-provider");
5
6
  const base_command_1 = require("../../base-command");
6
7
  const cli_installers_1 = require("../../lib/cli-installers");
7
- const daemon_ref_1 = require("../../lib/daemon-ref");
8
8
  const daemon_target_1 = require("../../lib/daemon-target");
9
9
  /**
10
10
  * Install one managed coding-agent CLI on a machine.
@@ -32,10 +32,11 @@ const daemon_target_1 = require("../../lib/daemon-target");
32
32
  * come back as an opaque refusal.
33
33
  */
34
34
  class HarnessesInstall extends base_command_1.BaseCommand {
35
- static description = 'Install a managed coding-agent CLI (claude, codex, devin, …) on one of your machines';
35
+ static description = 'Install a managed coding-agent CLI (claude, codex, devin, …) on one of your machines, or update one already installed (--update)';
36
36
  static examples = [
37
37
  '<%= config.bin %> harnesses install devin',
38
38
  '<%= config.bin %> harnesses install codex --daemon daemon_07c086e3c249_cli_default',
39
+ '<%= config.bin %> harnesses install claude --update',
39
40
  ];
40
41
  static args = {
41
42
  provider: core_1.Args.string({
@@ -49,6 +50,9 @@ class HarnessesInstall extends base_command_1.BaseCommand {
49
50
  daemon: core_1.Flags.string({
50
51
  description: 'Which machine to install on. Defaults to your only connected daemon; required when several are connected.',
51
52
  }),
53
+ update: core_1.Flags.boolean({
54
+ description: "Update the CLI already installed, with that CLI's own updater. When a CLI updates some other way (e.g. inside its desktop app), the daemon says so instead.",
55
+ }),
52
56
  };
53
57
  async run() {
54
58
  this.requireAuth();
@@ -66,6 +70,10 @@ class HarnessesInstall extends base_command_1.BaseCommand {
66
70
  listActiveDaemons: () => (0, daemon_target_1.listConnectedDaemons)(this.resolvedCredential),
67
71
  });
68
72
  const daemonId = this.daemonIdFor(target);
73
+ if (flags.update) {
74
+ await this.update(daemonId, provider, Boolean(flags.json));
75
+ return;
76
+ }
69
77
  let start;
70
78
  try {
71
79
  start = await (0, cli_installers_1.startManagedCliInstall)(daemonId, provider, this.resolvedCredential);
@@ -83,27 +91,22 @@ class HarnessesInstall extends base_command_1.BaseCommand {
83
91
  const reason = start.provider?.installDisabledReason?.trim() ||
84
92
  (await this.installerNote(daemonId, provider));
85
93
  this.log(reason ? `${provider}: ${reason}` : `${provider}: the daemon started no install job.`);
94
+ // "Already installed." used to be the end of the road: the product could
95
+ // install a CLI and then had no word about updating it (OSK-10096).
96
+ if (start.alreadyInstalled) {
97
+ const next = updateHint(provider, start.provider, this.config.bin);
98
+ if (next)
99
+ this.log(next);
100
+ }
86
101
  return;
87
102
  }
103
+ // The raw id: it is what `--daemon` takes, and a formatDaemonRef prefix of a
104
+ // `daemon_<hash>_…` id reads `d_daemon_4...` for every local daemon (OSK-10182).
88
105
  if (!flags.json)
89
- this.logToStderr(`Installing ${provider} on ${(0, daemon_ref_1.formatDaemonRef)(daemonId)}…`);
90
- const outcome = await (0, cli_installers_1.followManagedCliInstallJob)({
91
- daemonId,
92
- jobId,
93
- credential: this.resolvedCredential,
94
- onLogLine: flags.json ? undefined : (line) => this.logToStderr(` ${line}`),
95
- });
96
- if (outcome.kind === 'missing') {
97
- this.error(`The daemon no longer has install job ${jobId}.`, { exit: 1 });
106
+ this.logToStderr(`Installing ${provider} on ${daemonId}…`);
107
+ const job = await this.follow(daemonId, jobId, 'install', Boolean(flags.json));
108
+ if (!job)
98
109
  return;
99
- }
100
- if (outcome.kind === 'timeout') {
101
- // Not a failure: the daemon is still working, and calling this failed
102
- // would send someone to debug a download that is going fine.
103
- this.error(`Still installing after ${Math.round(cli_installers_1.INSTALL_MAX_WAIT_MS / 60000)} minutes — the daemon is still working on job ${jobId}. Check \`${this.config.bin} harnesses installers\` in a while.`, { exit: 1 });
104
- return;
105
- }
106
- const job = outcome.job;
107
110
  if (flags.json) {
108
111
  this.log(JSON.stringify({ daemonId, provider, job }, null, 2));
109
112
  if (job.status !== 'succeeded')
@@ -123,6 +126,99 @@ class HarnessesInstall extends base_command_1.BaseCommand {
123
126
  const state = await this.installerNote(daemonId, provider, true);
124
127
  this.log(`${provider} installed.${state ? ` ${state}` : ''}`);
125
128
  }
129
+ /**
130
+ * Update an installed CLI in place (OSK-10096).
131
+ *
132
+ * Asks the inventory first rather than posting blind: a daemon older than
133
+ * managed updates has no handler for the request and would leave it to time
134
+ * out, and a CLI that updates some other way (the copy inside Devin.app) has a
135
+ * reason the daemon already words. Both are answered before anything runs.
136
+ */
137
+ async update(daemonId, provider, json) {
138
+ let inventory;
139
+ try {
140
+ inventory = await (0, cli_installers_1.listCliInstallers)(daemonId, this.resolvedCredential);
141
+ }
142
+ catch (err) {
143
+ this.handleApiError(err);
144
+ return;
145
+ }
146
+ const row = (inventory.providers ?? []).find((p) => (0, data_provider_1.normalizeManagedCliProvider)(p.provider) === provider);
147
+ if (!row || row.updatable === undefined) {
148
+ this.failWithCliError({
149
+ message: `The daemon ${daemonId} (${inventory.daemonVersion ?? 'version not reported'}) cannot update ${provider}: it predates managed updates. ` +
150
+ `Update the daemon, then run this again — \`${this.config.bin} daemon status\` says how.`,
151
+ code: 'DAEMON_UPDATE_UNSUPPORTED',
152
+ exit: 1,
153
+ retryable: false,
154
+ });
155
+ return;
156
+ }
157
+ if (!row.updatable) {
158
+ this.failWithCliError({
159
+ message: `${provider}: ${row.updateDisabledReason?.trim() || 'the daemon cannot update it here.'}`,
160
+ code: 'CLI_NOT_UPDATABLE',
161
+ exit: 1,
162
+ retryable: false,
163
+ });
164
+ return;
165
+ }
166
+ let start;
167
+ try {
168
+ start = await (0, cli_installers_1.startManagedCliUpdate)(daemonId, provider, this.resolvedCredential);
169
+ }
170
+ catch (err) {
171
+ this.handleApiError(err);
172
+ return;
173
+ }
174
+ const jobId = start.job?.id;
175
+ if (!start.started || !jobId) {
176
+ this.log(start.alreadyRunning
177
+ ? `${provider}: an install or update is already running (job ${start.job?.id ?? 'unknown'}).`
178
+ : `${provider}: ${start.provider?.updateDisabledReason?.trim() || 'the daemon started no update job.'}`);
179
+ return;
180
+ }
181
+ if (!json) {
182
+ this.logToStderr(`Updating ${provider} ${row.version ? `${row.version} ` : ''}on ${daemonId}…`);
183
+ }
184
+ const job = await this.follow(daemonId, jobId, 'update', json);
185
+ if (!job)
186
+ return;
187
+ if (json) {
188
+ this.log(JSON.stringify({ daemonId, provider, job }, null, 2));
189
+ if (job.status !== 'succeeded')
190
+ process.exitCode = 1;
191
+ return;
192
+ }
193
+ if (job.status === 'failed') {
194
+ this.error(`Updating ${provider} failed: ${job.error || 'the daemon gave no reason.'}`, {
195
+ exit: 1,
196
+ });
197
+ return;
198
+ }
199
+ this.log((0, cli_installers_1.updateOutcomeLine)(job) ??
200
+ `${provider} updated${job.installedVersion ? ` to ${job.installedVersion}` : ''}.`);
201
+ }
202
+ /** Follow a job to its end, or report why it could not be followed (and return nothing). */
203
+ async follow(daemonId, jobId, verb, json) {
204
+ const outcome = await (0, cli_installers_1.followManagedCliInstallJob)({
205
+ daemonId,
206
+ jobId,
207
+ credential: this.resolvedCredential,
208
+ onLogLine: json ? undefined : (line) => this.logToStderr(` ${line}`),
209
+ });
210
+ if (outcome.kind === 'missing') {
211
+ this.error(`The daemon no longer has ${verb} job ${jobId}.`, { exit: 1 });
212
+ return undefined;
213
+ }
214
+ if (outcome.kind === 'timeout') {
215
+ // Not a failure: the daemon is still working, and calling this failed
216
+ // would send someone to debug a download that is going fine.
217
+ this.error(`Still ${verb === 'install' ? 'installing' : 'updating'} after ${Math.round(cli_installers_1.INSTALL_MAX_WAIT_MS / 60000)} minutes — the daemon is still working on job ${jobId}. Check \`${this.config.bin} harnesses installers\` in a while.`, { exit: 1 });
218
+ return undefined;
219
+ }
220
+ return outcome.job;
221
+ }
126
222
  /** What the daemon says about this provider right now, best effort. */
127
223
  async installerNote(daemonId, provider, readiness = false) {
128
224
  try {
@@ -175,3 +271,16 @@ class HarnessesInstall extends base_command_1.BaseCommand {
175
271
  }
176
272
  }
177
273
  exports.default = HarnessesInstall;
274
+ /**
275
+ * The next step after "Already installed.": how to update it, or where its
276
+ * update comes from — or nothing from a daemon that cannot say (OSK-10096).
277
+ */
278
+ function updateHint(provider, state, bin) {
279
+ if (!state || state.updatable === undefined)
280
+ return null;
281
+ if (state.updatable) {
282
+ return `To update it: ${bin} harnesses install ${provider} --update`;
283
+ }
284
+ const reason = state.updateDisabledReason?.trim();
285
+ return reason ? `Updating: ${reason}` : null;
286
+ }
@@ -140,35 +140,26 @@ class SkillsImportAsActions extends base_command_1.BaseCommand {
140
140
  this.handleApiError(err);
141
141
  return;
142
142
  }
143
- const failed = result.results.filter((r) => r.status === 'failed');
143
+ const { rows, summary, failed } = (0, local_skills_1.summarizeSkillActionImport)(result);
144
144
  if (flags.json) {
145
145
  this.log(JSON.stringify(result, null, 2));
146
146
  }
147
147
  else {
148
- (0, format_1.renderTable)(result.results.map((r) => ({
149
- skill: r.name,
150
- import: r.status,
151
- action: r.actionCreated
152
- ? 'created'
153
- : (r.actionSkippedReason ??
154
- r.actionError ??
155
- (r.status === 'failed' ? '-' : 'not added')),
156
- detail: r.error ?? '',
157
- })), [
148
+ (0, format_1.renderTable)(rows, [
158
149
  { key: 'skill', header: 'SKILL' },
159
150
  { key: 'import', header: 'IMPORT' },
160
151
  { key: 'action', header: 'ACTION' },
161
152
  { key: 'detail', header: 'DETAIL' },
162
153
  ], (line) => this.log(line));
163
- this.log(`${result.actionsCreated} action(s) created on ${result.targetAgentId}; ` +
164
- `${failed.length} skill(s) failed.`);
154
+ this.log(summary);
165
155
  if (result.actionsError) {
166
156
  this.log(`The skills imported, but their actions could not be added: ${result.actionsError.message}`);
167
157
  }
168
158
  }
169
159
  // Partial success is normal and reported above; exit non-zero only when
170
- // something the caller asked for did not happen.
171
- if (failed.length > 0 || result.actionsError)
160
+ // something the caller asked for did not happen — including an action the
161
+ // full library could not take.
162
+ if (failed)
172
163
  this.exit(1);
173
164
  }
174
165
  }
@@ -255,6 +255,19 @@ class TasksComplete extends base_command_1.BaseCommand {
255
255
  });
256
256
  }
257
257
  catch (error) {
258
+ // Completing records a Result, so a closed Task refuses it the same way
259
+ // `tasks result submit` is refused, with the same way forward.
260
+ const refusal = (0, base_command_1.apiRefusal)(error);
261
+ if (refusal.code === 'TASK_REOPEN_REQUIRED') {
262
+ this.error([
263
+ refusal.message ?? 'This Task is closed.',
264
+ ...(0, task_closure_1.reopenRequiredLines)({
265
+ bin: this.config.bin,
266
+ task: identity.id,
267
+ command: 'complete',
268
+ }),
269
+ ].join('\n'), { exit: 1, code: 'TASK_REOPEN_REQUIRED' });
270
+ }
258
271
  this.handleApiError(error);
259
272
  }
260
273
  const url = (0, web_url_1.taskUrlFrom)(this.cliConfig.baseURL, { id: taskId });
@@ -122,14 +122,19 @@ class TasksResultSubmit extends base_command_1.BaseCommand {
122
122
  : await data_provider_1.dataService.submitTaskResult({ taskId: task.id, data });
123
123
  }
124
124
  catch (error) {
125
- // A closed Task refuses a Result. Name the two ways forward here, where
126
- // the commands are known: the API's own message stays surface-neutral
125
+ // A closed Task refuses a Result. Name the way forward here, where the
126
+ // commands are known: the API's own message stays surface-neutral
127
127
  // (OSK-10031).
128
128
  const refusal = (0, base_command_1.apiRefusal)(error);
129
129
  if (refusal.code === 'TASK_REOPEN_REQUIRED') {
130
- this.error(`${refusal.message ?? 'This Task is closed.'}\n` +
131
- `→ Reopen it and submit again: ${this.config.bin} tasks move ${task.id} --status in_progress\n` +
132
- `→ Or record the Result and close it in one step: ${this.config.bin} tasks complete ${task.id} --summary "…"`, { exit: 1, code: 'TASK_REOPEN_REQUIRED' });
130
+ this.error([
131
+ refusal.message ?? 'This Task is closed.',
132
+ ...(0, task_closure_1.reopenRequiredLines)({
133
+ bin: this.config.bin,
134
+ task: task.id,
135
+ command: 'result submit',
136
+ }),
137
+ ].join('\n'), { exit: 1, code: 'TASK_REOPEN_REQUIRED' });
133
138
  }
134
139
  this.handleApiError(error);
135
140
  }
@@ -25,6 +25,13 @@ export interface ManagedCliProviderState {
25
25
  source?: string;
26
26
  installable?: boolean;
27
27
  installDisabledReason?: string | null;
28
+ /**
29
+ * Whether the daemon can update this installed CLI in place, and why not.
30
+ * ABSENT from a daemon older than managed updates (OSK-10096) — read that as
31
+ * "this daemon cannot say", never as `false`.
32
+ */
33
+ updatable?: boolean;
34
+ updateDisabledReason?: string | null;
28
35
  platformSupported?: boolean;
29
36
  authentication?: ManagedCliAuthentication | null;
30
37
  job?: {
@@ -143,18 +150,33 @@ export declare function readinessForRows(rows: readonly ReadinessRow[], credenti
143
150
  }>;
144
151
  export interface ManagedCliInstallJob {
145
152
  id: string;
153
+ provider?: string;
154
+ displayName?: string;
146
155
  status: 'queued' | 'running' | 'succeeded' | 'failed';
156
+ /** `update` for an in-place update; absent from an older daemon, meaning install. */
157
+ kind?: 'install' | 'update';
158
+ previousVersion?: string | null;
159
+ installedVersion?: string | null;
147
160
  error?: string;
148
161
  /**
149
162
  * The daemon's job log: the LAST lines only (a sliding window, 200 lines in
150
163
  * `daemon/src/cli-installers.ts`). This is the field the daemon sends.
151
164
  */
152
165
  logTail?: string[];
166
+ /**
167
+ * Lines the job has EVER logged (daemons with OSK-10179). With it, the lines
168
+ * new since the last poll are exactly the last `count − printed` of the tail,
169
+ * even when they repeat.
170
+ */
171
+ logLineCount?: number;
153
172
  /** Never sent by any daemon; kept so an older caller's shape still types. */
154
173
  log?: string[];
155
174
  }
156
175
  export interface StartInstallResponse {
157
176
  started?: boolean;
177
+ /** The daemon declined because the CLI is already there. */
178
+ alreadyInstalled?: boolean;
179
+ alreadyRunning?: boolean;
158
180
  provider?: ManagedCliProviderState;
159
181
  job?: {
160
182
  id?: string;
@@ -162,6 +184,14 @@ export interface StartInstallResponse {
162
184
  };
163
185
  }
164
186
  export declare function startManagedCliInstall(daemonId: string, provider: string, credential?: unknown): Promise<StartInstallResponse>;
187
+ /** Ask the daemon to update an installed CLI in place (OSK-10096). */
188
+ export declare function startManagedCliUpdate(daemonId: string, provider: string, credential?: unknown): Promise<StartInstallResponse>;
189
+ /**
190
+ * How an update job ended, from the versions the daemon recorded on it:
191
+ * "Updated Claude Code 2.1.270 → 2.1.280." or "Claude Code is still 2.1.270:
192
+ * its updater found nothing newer." Null when the job does not carry both.
193
+ */
194
+ export declare function updateOutcomeLine(job: ManagedCliInstallJob): string | null;
165
195
  export declare function readManagedCliInstallJob(daemonId: string, jobId: string, credential?: unknown): Promise<{
166
196
  job?: ManagedCliInstallJob;
167
197
  }>;
@@ -174,6 +204,16 @@ export declare function readManagedCliInstallJob(daemonId: string, jobId: string
174
204
  * the end of what was already printed, and print only what follows it.
175
205
  */
176
206
  export declare function unseenLogLines(printed: readonly string[], tail: readonly string[]): string[];
207
+ /**
208
+ * The new lines of a job's tail when the daemon reports its running line count:
209
+ * exactly the last `total − printedCount`, and how many of those already fell
210
+ * out of the window (the daemon keeps the last 200). Unlike overlap matching,
211
+ * this cannot mistake a repeated line for one already printed.
212
+ */
213
+ export declare function newLogLinesByCount(printedCount: number, tail: readonly string[], total: number): {
214
+ lines: string[];
215
+ skipped: number;
216
+ };
177
217
  /** A 100 MB download over a hotel connection is the case this has to survive. */
178
218
  export declare const INSTALL_MAX_WAIT_MS: number;
179
219
  export declare const INSTALL_POLL_INTERVAL_MS = 2000;
@@ -9,8 +9,11 @@ exports.readinessByProvider = readinessByProvider;
9
9
  exports.readinessKey = readinessKey;
10
10
  exports.readinessForRows = readinessForRows;
11
11
  exports.startManagedCliInstall = startManagedCliInstall;
12
+ exports.startManagedCliUpdate = startManagedCliUpdate;
13
+ exports.updateOutcomeLine = updateOutcomeLine;
12
14
  exports.readManagedCliInstallJob = readManagedCliInstallJob;
13
15
  exports.unseenLogLines = unseenLogLines;
16
+ exports.newLogLinesByCount = newLogLinesByCount;
14
17
  exports.followManagedCliInstallJob = followManagedCliInstallJob;
15
18
  /**
16
19
  * The daemon's managed-CLI installer inventory, as the CLI reads it.
@@ -211,6 +214,25 @@ async function readinessForRows(rows, credential, opts = {}) {
211
214
  async function startManagedCliInstall(daemonId, provider, credential) {
212
215
  return (0, api_fetch_1.apiFetch)(`/api/daemons/${encodeURIComponent(daemonId)}/cli-installers/${encodeURIComponent(provider)}/install`, { method: 'POST', body: {}, credential: credential });
213
216
  }
217
+ /** Ask the daemon to update an installed CLI in place (OSK-10096). */
218
+ async function startManagedCliUpdate(daemonId, provider, credential) {
219
+ return (0, api_fetch_1.apiFetch)(`/api/daemons/${encodeURIComponent(daemonId)}/cli-installers/${encodeURIComponent(provider)}/update`, { method: 'POST', body: {}, credential: credential });
220
+ }
221
+ /**
222
+ * How an update job ended, from the versions the daemon recorded on it:
223
+ * "Updated Claude Code 2.1.270 → 2.1.280." or "Claude Code is still 2.1.270:
224
+ * its updater found nothing newer." Null when the job does not carry both.
225
+ */
226
+ function updateOutcomeLine(job) {
227
+ const before = job.previousVersion;
228
+ const after = job.installedVersion;
229
+ if (!before || !after)
230
+ return null;
231
+ const name = job.displayName || job.provider || 'The CLI';
232
+ return before === after
233
+ ? `${name} is still ${after}: its updater found nothing newer.`
234
+ : `Updated ${name} ${before} → ${after}.`;
235
+ }
214
236
  async function readManagedCliInstallJob(daemonId, jobId, credential) {
215
237
  return (0, api_fetch_1.apiFetch)(`/api/daemons/${encodeURIComponent(daemonId)}/cli-installers/jobs/${encodeURIComponent(jobId)}`, { credential: credential });
216
238
  }
@@ -237,6 +259,20 @@ function unseenLogLines(printed, tail) {
237
259
  }
238
260
  return [...tail];
239
261
  }
262
+ /**
263
+ * The new lines of a job's tail when the daemon reports its running line count:
264
+ * exactly the last `total − printedCount`, and how many of those already fell
265
+ * out of the window (the daemon keeps the last 200). Unlike overlap matching,
266
+ * this cannot mistake a repeated line for one already printed.
267
+ */
268
+ function newLogLinesByCount(printedCount, tail, total) {
269
+ const unseen = Math.max(0, total - printedCount);
270
+ if (unseen === 0)
271
+ return { lines: [], skipped: 0 };
272
+ if (unseen > tail.length)
273
+ return { lines: [...tail], skipped: unseen - tail.length };
274
+ return { lines: tail.slice(tail.length - unseen), skipped: 0 };
275
+ }
240
276
  /** A 100 MB download over a hotel connection is the case this has to survive. */
241
277
  exports.INSTALL_MAX_WAIT_MS = 10 * 60_000;
242
278
  exports.INSTALL_POLL_INTERVAL_MS = 2_000;
@@ -256,6 +292,7 @@ exports.INSTALL_POLL_INTERVAL_MS = 2_000;
256
292
  async function followManagedCliInstallJob(input) {
257
293
  const deadline = Date.now() + (input.maxWaitMs ?? exports.INSTALL_MAX_WAIT_MS);
258
294
  let printed = [];
295
+ let printedCount = 0;
259
296
  for (;;) {
260
297
  const res = await readManagedCliInstallJob(input.daemonId, input.jobId, input.credential);
261
298
  const job = res.job;
@@ -264,7 +301,20 @@ async function followManagedCliInstallJob(input) {
264
301
  if (input.onLogLine) {
265
302
  // `logTail`, the field the daemon sends — this read `log`, which no daemon
266
303
  // has ever sent, so every install printed nothing until it ended (OSK-10103).
267
- const fresh = unseenLogLines(printed, job.logTail ?? job.log ?? []);
304
+ const tail = job.logTail ?? job.log ?? [];
305
+ let fresh;
306
+ if (typeof job.logLineCount === 'number') {
307
+ // Exact: the daemon says how many lines exist in total (OSK-10179).
308
+ const { lines, skipped } = newLogLinesByCount(printedCount, tail, job.logLineCount);
309
+ if (skipped > 0) {
310
+ input.onLogLine(`(… ${skipped} log line${skipped === 1 ? '' : 's'} not shown)`);
311
+ }
312
+ fresh = lines;
313
+ printedCount = Math.max(printedCount, job.logLineCount);
314
+ }
315
+ else {
316
+ fresh = unseenLogLines(printed, tail);
317
+ }
268
318
  for (const line of fresh)
269
319
  input.onLogLine(line);
270
320
  printed = [...printed, ...fresh].slice(-MAX_TRACKED_LOG_LINES);
@@ -56,6 +56,8 @@ export interface FollowUpRow {
56
56
  export interface FollowUpCreateResponse {
57
57
  followup: FollowUpRow | null;
58
58
  reused?: boolean;
59
+ /** A reused reminder asked for at a different time moved to that time. */
60
+ rescheduled?: boolean;
59
61
  dropped?: false | 'budget';
60
62
  proposal?: boolean;
61
63
  budget?: Json;
@@ -112,3 +112,30 @@ export declare const SKILL_CONVENTION_DISCOVERY_SINCE = "0.8.44";
112
112
  * add an explanation, and claiming an unknown daemon is old would be a guess.
113
113
  */
114
114
  export declare function daemonPredates(version: string | null | undefined, floor: string): boolean;
115
+ /**
116
+ * The table, summary and exit decision for `skills import-as-actions`.
117
+ *
118
+ * A skill that imported without its action because the library was full used to
119
+ * show only `limit_reached`, an empty detail, "0 skill(s) failed" and exit 0, so
120
+ * a script or agent concluded every action it asked for was added (OSK-10185).
121
+ * `already_exists` stays a success: the action the caller wanted is there.
122
+ */
123
+ export declare function summarizeSkillActionImport(result: {
124
+ targetAgentId: string;
125
+ actionsCreated: number;
126
+ results: Array<{
127
+ name: string;
128
+ status: string;
129
+ error?: string;
130
+ actionCreated?: boolean;
131
+ actionSkippedReason?: string;
132
+ actionError?: string;
133
+ }>;
134
+ actionsError?: {
135
+ message: string;
136
+ } | null;
137
+ }): {
138
+ rows: Array<Record<string, string>>;
139
+ summary: string;
140
+ failed: boolean;
141
+ };
@@ -10,6 +10,7 @@ exports.looksLikeSkillPath = looksLikeSkillPath;
10
10
  exports.findLocalSkillsNamed = findLocalSkillsNamed;
11
11
  exports.describeStoreSkillMiss = describeStoreSkillMiss;
12
12
  exports.daemonPredates = daemonPredates;
13
+ exports.summarizeSkillActionImport = summarizeSkillActionImport;
13
14
  const data_provider_1 = require("@skrr-ai/data-provider");
14
15
  const instruction_input_1 = require("./instruction-input");
15
16
  /**
@@ -211,3 +212,40 @@ function daemonPredates(version, floor) {
211
212
  }
212
213
  return false;
213
214
  }
215
+ /** Why a skill imported but got no action, in words a person or an agent can act on. */
216
+ const ACTION_SKIP_DETAIL = {
217
+ limit_reached: "this agent's action library is full; delete an action, then run the import again to add it",
218
+ already_exists: 'an action for this skill is already offered at this scope',
219
+ };
220
+ /**
221
+ * The table, summary and exit decision for `skills import-as-actions`.
222
+ *
223
+ * A skill that imported without its action because the library was full used to
224
+ * show only `limit_reached`, an empty detail, "0 skill(s) failed" and exit 0, so
225
+ * a script or agent concluded every action it asked for was added (OSK-10185).
226
+ * `already_exists` stays a success: the action the caller wanted is there.
227
+ */
228
+ function summarizeSkillActionImport(result) {
229
+ const failed = result.results.filter((r) => r.status === 'failed');
230
+ const overCap = result.results.filter((r) => r.status !== 'failed' && r.actionSkippedReason === 'limit_reached');
231
+ const rows = result.results.map((r) => ({
232
+ skill: r.name,
233
+ import: r.status,
234
+ action: r.actionCreated
235
+ ? 'created'
236
+ : (r.actionSkippedReason ?? r.actionError ?? (r.status === 'failed' ? '-' : 'not added')),
237
+ detail: r.error ??
238
+ r.actionError ??
239
+ (r.actionSkippedReason ? (ACTION_SKIP_DETAIL[r.actionSkippedReason] ?? '') : ''),
240
+ }));
241
+ const summary = `${result.actionsCreated} action(s) created on ${result.targetAgentId}; ` +
242
+ `${failed.length} skill(s) failed` +
243
+ (overCap.length > 0
244
+ ? `; ${overCap.length} skill(s) imported without an action because the action library is full.`
245
+ : '.');
246
+ return {
247
+ rows,
248
+ summary,
249
+ failed: failed.length > 0 || overCap.length > 0 || Boolean(result.actionsError),
250
+ };
251
+ }
@@ -28,6 +28,19 @@ export type ClosureLike = TaskClosureEvaluation | {
28
28
  * the whole point is to be able to act without a second lookup.
29
29
  */
30
30
  export declare function closureReport(closure: ClosureLike): string[];
31
+ /**
32
+ * The ways forward when a closed Task refuses a Result (`TASK_REOPEN_REQUIRED`).
33
+ *
34
+ * Both writers reach this refusal, `tasks result submit` and `tasks complete`,
35
+ * so both print from here. The first version sat in `result submit` alone and
36
+ * offered `tasks complete` as a way around it, which the same refusal stops:
37
+ * every step named here has to work on a closed Task (OSK-10031).
38
+ */
39
+ export declare function reopenRequiredLines({ bin, task, command, }: {
40
+ bin: string;
41
+ task: string;
42
+ command: 'result submit' | 'complete';
43
+ }): string[];
31
44
  /** `met (agent)` vs `met (person)` — shared with the web through data-provider. */
32
45
  export { describeAssessorKind as describeAssessor } from '@skrr-ai/data-provider';
33
46
  export { describeClosure as closureSnapshotLines, type TaskClosureSnapshot as ClosureSnapshot, } from '@skrr-ai/data-provider';