@skrr-ai/cli 0.1.58 → 0.1.60
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/dist/base-command.d.ts +0 -16
- package/dist/base-command.js +65 -11
- package/dist/commands/followups/checkin.js +16 -3
- package/dist/commands/logout.d.ts +0 -46
- package/dist/commands/logout.js +21 -3
- package/dist/lib/agentic-stream.d.ts +49 -0
- package/dist/lib/agentic-stream.js +140 -15
- package/dist/lib/api-fetch.d.ts +11 -0
- package/dist/lib/api-fetch.js +98 -1
- package/dist/lib/daemonBrokerRefusal.d.ts +21 -0
- package/dist/lib/daemonBrokerRefusal.js +87 -2
- package/dist/lib/dedicated-guest.d.ts +13 -0
- package/dist/lib/dedicated-guest.js +22 -0
- package/dist/lib/dedicated-machines.js +17 -1
- package/dist/lib/dedicated-service.d.ts +1 -13
- package/dist/lib/dedicated-service.js +7 -19
- package/dist/lib/followups.d.ts +7 -0
- package/dist/lib/followups.js +17 -4
- package/dist/lib/login.js +10 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialEnvelopeBridge.d.ts +41 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialEnvelopeBridge.js +147 -3
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.d.ts +17 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.js +38 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.d.ts +2 -2
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.js +5 -3
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/profileStateDir.d.ts +21 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/profileStateDir.js +40 -7
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialEnvelopeBridge.d.ts +41 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialEnvelopeBridge.js +147 -4
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.d.ts +17 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.js +38 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.d.ts +2 -2
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.js +2 -2
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/profileStateDir.d.ts +21 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/profileStateDir.js +39 -6
- package/dist/node_modules/@skrr-ai/auth-core/package.json +1 -1
- package/dist/node_modules/@skrr-ai/data-provider/index.js +4397 -4363
- package/oclif.manifest.json +17475 -17475
- package/package.json +1 -1
package/dist/base-command.d.ts
CHANGED
|
@@ -20,22 +20,6 @@ type OclifFlagOutput = {
|
|
|
20
20
|
type OclifArgOutput = {
|
|
21
21
|
[name: string]: unknown;
|
|
22
22
|
};
|
|
23
|
-
/**
|
|
24
|
-
* Base class for every CLI command.
|
|
25
|
-
*
|
|
26
|
-
* Responsibilities:
|
|
27
|
-
* - Run the one-shot auth-out-of-config migration (Phase H).
|
|
28
|
-
* - Parse global flags (`--bare`, `--token`) from argv once and thread
|
|
29
|
-
* them through the credential resolver and the HTTP adapter.
|
|
30
|
-
* - Resolve the active credential via the CredentialResolver chain —
|
|
31
|
-
* flag → env → helper → keychain → file (Phase H.1). Returns the
|
|
32
|
-
* source name for display in `skrr whoami` / `skrr doctor`.
|
|
33
|
-
* - Wire the Node HttpAdapter into @skrr-ai/data-provider so
|
|
34
|
-
* `dataService.*` calls use our fetch-based transport with bearer
|
|
35
|
-
* auth that matches what the resolver surfaced.
|
|
36
|
-
* - Provide `requireAuth()` for commands that need a signed-in user,
|
|
37
|
-
* and `handleApiError()` for uniform error translation.
|
|
38
|
-
*/
|
|
39
23
|
export declare abstract class BaseCommand extends Command {
|
|
40
24
|
/** Add the owning agent's web detail link without overwriting resource URLs. */
|
|
41
25
|
protected withAgentContext<T>(agentId: string, value: T): T & {
|
package/dist/base-command.js
CHANGED
|
@@ -16,6 +16,7 @@ exports.isDaemonScopedCredential = isDaemonScopedCredential;
|
|
|
16
16
|
exports.describeDaemonScopedCredential = describeDaemonScopedCredential;
|
|
17
17
|
exports.isTypedServerRefusal = isTypedServerRefusal;
|
|
18
18
|
exports.isRetryableFailure = isRetryableFailure;
|
|
19
|
+
const node_fs_1 = require("node:fs");
|
|
19
20
|
const core_1 = require("@oclif/core");
|
|
20
21
|
Object.defineProperty(exports, "Flags", { enumerable: true, get: function () { return core_1.Flags; } });
|
|
21
22
|
const option_hint_1 = require("./lib/option-hint");
|
|
@@ -51,6 +52,7 @@ const update_check_1 = require("./lib/update-check");
|
|
|
51
52
|
const web_url_1 = require("./lib/web-url");
|
|
52
53
|
const edge_error_page_1 = require("./lib/edge-error-page");
|
|
53
54
|
const delegated_cli_1 = require("./lib/delegated-cli");
|
|
55
|
+
const dedicated_guest_1 = require("./lib/dedicated-guest");
|
|
54
56
|
const tasks_1 = require("./lib/tasks");
|
|
55
57
|
function assigneePayloadNeedsResolution(payload) {
|
|
56
58
|
const fields = [
|
|
@@ -82,6 +84,25 @@ function assigneePayloadNeedsResolution(payload) {
|
|
|
82
84
|
* - Provide `requireAuth()` for commands that need a signed-in user,
|
|
83
85
|
* and `handleApiError()` for uniform error translation.
|
|
84
86
|
*/
|
|
87
|
+
/**
|
|
88
|
+
* On a Dedicated Runtime guest, the description of a daemon that did not
|
|
89
|
+
* answer the hand-off at all — revoked and exited, cycling under systemd, or
|
|
90
|
+
* gone behind a stale descriptor (OSK-12473). Null anywhere else, where "Not
|
|
91
|
+
* signed in. Run `skrr login` first." is the right sentence.
|
|
92
|
+
*/
|
|
93
|
+
function describeAbsentGuestDaemon(outcome, bin) {
|
|
94
|
+
const onGuest = outcome.dedicatedGuest === true || (0, dedicated_guest_1.runningOnDedicatedRuntimeGuest)();
|
|
95
|
+
if (!onGuest)
|
|
96
|
+
return null;
|
|
97
|
+
let descriptorPresent = false;
|
|
98
|
+
try {
|
|
99
|
+
descriptorPresent = (0, node_fs_1.existsSync)(daemonBroker_1.DEDICATED_RUNTIME_CLI_HANDOFF_DESCRIPTOR);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
/* unreadable reads as absent */
|
|
103
|
+
}
|
|
104
|
+
return (0, daemonBrokerRefusal_1.describeGuestDaemonUnavailable)(outcome, { bin, onGuest, descriptorPresent });
|
|
105
|
+
}
|
|
85
106
|
class BaseCommand extends core_1.Command {
|
|
86
107
|
/** Add the owning agent's web detail link without overwriting resource URLs. */
|
|
87
108
|
withAgentContext(agentId, value) {
|
|
@@ -375,7 +396,9 @@ class BaseCommand extends core_1.Command {
|
|
|
375
396
|
}
|
|
376
397
|
: null;
|
|
377
398
|
}
|
|
378
|
-
else if ((this.brokerRefusal =
|
|
399
|
+
else if ((this.brokerRefusal =
|
|
400
|
+
(0, daemonBrokerRefusal_1.describeBrokerRefusal)(autoResult.outcome, this.config.bin) ??
|
|
401
|
+
describeAbsentGuestDaemon(autoResult.outcome, this.config.bin))) {
|
|
379
402
|
// Reported by requireAuth(); nothing to print here.
|
|
380
403
|
}
|
|
381
404
|
else if (autoResult.outcome.reason === 'rate_limited') {
|
|
@@ -1140,19 +1163,45 @@ class BaseCommand extends core_1.Command {
|
|
|
1140
1163
|
bin: this.config.bin,
|
|
1141
1164
|
cliId: this.cliConfig ? (0, cli_id_1.resolveCliId)(this.cliConfig) : undefined,
|
|
1142
1165
|
});
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1166
|
+
// OSK-12474 — `apiFetch` asked the local daemon for a hand-off after the
|
|
1167
|
+
// refused refresh, and the daemon refused too. Its reason decides the
|
|
1168
|
+
// repair (OSK-12149, OSK-12151), and on a Dedicated guest there is no
|
|
1169
|
+
// login to run, so its remedy replaces the generic "Run login".
|
|
1170
|
+
const brokerRefusal = presentedCiToken || !e.brokerFailure
|
|
1171
|
+
? null
|
|
1172
|
+
: (0, daemonBrokerRefusal_1.describeBrokerRefusal)(e.brokerFailure, this.config.bin);
|
|
1173
|
+
const renewal = refreshRefusal
|
|
1174
|
+
? refreshRefusal.message.replace(/\s*Run `[^`]+` to sign in again\.$/, '')
|
|
1175
|
+
: "This CLI's own sign-in could not be renewed.";
|
|
1176
|
+
const details = {
|
|
1177
|
+
...(refreshRefusal && bodyCode ? { accessTokenCode: bodyCode } : {}),
|
|
1178
|
+
...(brokerRefusal
|
|
1179
|
+
? { broker: brokerRefusal.broker, suggestion: brokerRefusal.remedy }
|
|
1180
|
+
: {}),
|
|
1181
|
+
};
|
|
1182
|
+
let authMessage;
|
|
1183
|
+
if (presentedCiToken) {
|
|
1184
|
+
authMessage =
|
|
1185
|
+
`Authentication failed: this is an \`osk_ci_*\` token, which only bootstraps a ` +
|
|
1146
1186
|
`daemon (\`OVERSKY_TOKEN=<token> skrrd start\`). ${this.config.bin} commands do ` +
|
|
1147
|
-
`not accept it — sign in with \`${this.config.bin} login\` instead
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1187
|
+
`not accept it — sign in with \`${this.config.bin} login\` instead.`;
|
|
1188
|
+
}
|
|
1189
|
+
else if (brokerRefusal) {
|
|
1190
|
+
authMessage = `Authentication failed. ${renewal} ${brokerRefusal.headline}\n→ ${brokerRefusal.remedy}`;
|
|
1191
|
+
}
|
|
1192
|
+
else if (refreshRefusal) {
|
|
1193
|
+
authMessage = `Authentication failed. ${refreshRefusal.message}`;
|
|
1194
|
+
}
|
|
1195
|
+
else {
|
|
1196
|
+
authMessage = `Authentication failed. Run \`${this.config.bin} login\` to sign in again.`;
|
|
1197
|
+
}
|
|
1198
|
+
this.failWithCliError({
|
|
1199
|
+
message: say(authMessage),
|
|
1151
1200
|
code: refreshRefusal?.code || bodyCode || 'AUTHENTICATION_FAILED',
|
|
1152
1201
|
status: e.status,
|
|
1153
1202
|
exit: exitCode,
|
|
1154
1203
|
retryable: true,
|
|
1155
|
-
...(
|
|
1204
|
+
...(Object.keys(details).length > 0 ? { details } : {}),
|
|
1156
1205
|
});
|
|
1157
1206
|
}
|
|
1158
1207
|
else if ((e.status === 403 || e.status === 504) &&
|
|
@@ -1438,9 +1487,14 @@ class BaseCommand extends core_1.Command {
|
|
|
1438
1487
|
bin: this.config.bin,
|
|
1439
1488
|
cliId: this.cliConfig ? (0, cli_id_1.resolveCliId)(this.cliConfig) : undefined,
|
|
1440
1489
|
});
|
|
1441
|
-
const
|
|
1490
|
+
const brokerRefusal = e.brokerFailure
|
|
1491
|
+
? (0, daemonBrokerRefusal_1.describeBrokerRefusal)(e.brokerFailure, this.config.bin)
|
|
1492
|
+
: null;
|
|
1493
|
+
const why = (refusal
|
|
1442
1494
|
? refusal.message.replace(/\s*Run `[^`]+` to sign in again\.$/, '')
|
|
1443
|
-
: 'The server did not accept this sign-in.'
|
|
1495
|
+
: 'The server did not accept this sign-in.') +
|
|
1496
|
+
// OSK-12474 — the daemon was asked for a hand-off and refused; say why.
|
|
1497
|
+
(brokerRefusal ? ` ${brokerRefusal.headline}` : '');
|
|
1444
1498
|
this.failWithCliError({
|
|
1445
1499
|
message: `Authentication failed${statusSuffix}. ${why} Your ${write.summary} was not applied; it ` +
|
|
1446
1500
|
`was saved locally as ${queuedId} so it is not lost. Sign in with ` +
|
|
@@ -20,7 +20,7 @@ class FollowupsCheckin extends base_command_1.BaseCommand {
|
|
|
20
20
|
'<%= config.bin %> followups checkin --subject task:OSK-42 --why "weekly touch-base" --frequency "0 9 * * MON" --timezone America/New_York',
|
|
21
21
|
];
|
|
22
22
|
static flags = {
|
|
23
|
-
subject: core_1.Flags.string({ description:
|
|
23
|
+
subject: core_1.Flags.string({ description: followups_1.FOLLOWUP_SUBJECT_FLAG_HELP, required: true }),
|
|
24
24
|
why: core_1.Flags.string({ description: 'Why this wake exists, in one line', required: true }),
|
|
25
25
|
in: core_1.Flags.string({
|
|
26
26
|
description: 'Duration from now (90m, 2h) — the once form',
|
|
@@ -41,8 +41,21 @@ class FollowupsCheckin extends base_command_1.BaseCommand {
|
|
|
41
41
|
json: core_1.Flags.boolean({ description: 'Output as JSON' }),
|
|
42
42
|
};
|
|
43
43
|
async run() {
|
|
44
|
-
this.requireAuth();
|
|
45
44
|
const { flags } = await this.parse(FollowupsCheckin);
|
|
45
|
+
let subject;
|
|
46
|
+
try {
|
|
47
|
+
subject = (0, followups_1.parseSubjectRef)(flags.subject);
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
this.failWithCliError({
|
|
51
|
+
message: err instanceof Error ? err.message : String(err),
|
|
52
|
+
code: 'FOLLOWUP_SUBJECT_INVALID',
|
|
53
|
+
exit: 2,
|
|
54
|
+
retryable: false,
|
|
55
|
+
});
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
this.requireAuth();
|
|
46
59
|
if (!flags.in && !flags.frequency) {
|
|
47
60
|
this.failWithCliError({
|
|
48
61
|
message: 'Either --in <duration> or --frequency <cron> is required.',
|
|
@@ -71,7 +84,7 @@ class FollowupsCheckin extends base_command_1.BaseCommand {
|
|
|
71
84
|
kind: 'checkin',
|
|
72
85
|
producer: agent.boundToSession ? 'agent_in_turn' : 'user_explicit',
|
|
73
86
|
agentId: agent.id,
|
|
74
|
-
subject
|
|
87
|
+
subject,
|
|
75
88
|
why: flags.why,
|
|
76
89
|
...(flags.in ? { form: 'once', in: flags.in } : {}),
|
|
77
90
|
...(flags.frequency
|
|
@@ -5,52 +5,6 @@ export declare function shouldSkipLocalLogout(input: {
|
|
|
5
5
|
hasUnreadableEncryptedToken: boolean;
|
|
6
6
|
allDevices: boolean;
|
|
7
7
|
}): boolean;
|
|
8
|
-
/**
|
|
9
|
-
* `skrr logout` / `skrr logout --all-devices`
|
|
10
|
-
*
|
|
11
|
-
* Default mode (no flags): revokes the current CLI refresh session on the
|
|
12
|
-
* server (best-effort) and clears every local backend (Keychain on macOS,
|
|
13
|
-
* cli-auth.json fallback, and any legacy token fields in cli-config.json).
|
|
14
|
-
* `baseURL` and `cliId` are preserved so the next `skrr login` defaults to
|
|
15
|
-
* the same host and reuses the per-machine identity (which becomes a new
|
|
16
|
-
* refresh family — server-side revocation invalidated the prior family).
|
|
17
|
-
*
|
|
18
|
-
* `--all-devices` (L7 fleet-grade logout): in addition to the local
|
|
19
|
-
* cleanup, iterate the user's registered daemons and revoke each one
|
|
20
|
-
* server-side. This is the threat-model-correct response for a stolen-
|
|
21
|
-
* laptop scenario where the user wants every persistent daemon credential
|
|
22
|
-
* gone immediately, regardless of which machine they're calling from.
|
|
23
|
-
*
|
|
24
|
-
* Architectural note: the L7 server-side has a bulk endpoint
|
|
25
|
-
* (`POST /api/auth/sessions/revoke-all`) but it requires a user-scope JWT.
|
|
26
|
-
* CLI sessions are minted with `scope: 'cli'` (interactive single-machine
|
|
27
|
-
* use), so they cannot directly hit the bulk endpoint — that's by design,
|
|
28
|
-
* preventing a leaked CLI credential from kicking the user out of every
|
|
29
|
-
* other device. We instead iterate `DELETE /api/daemon-resources/:id`
|
|
30
|
-
* which CLI scope CAN hit (per-resource ownership check, not scope check).
|
|
31
|
-
* N round trips for N daemons; for typical fleet sizes (< 20) this is
|
|
32
|
-
* sub-second. Each per-daemon DELETE produces its own audit row keyed by
|
|
33
|
-
* `'user_initiated_per_device'`, which preserves forensic granularity.
|
|
34
|
-
*
|
|
35
|
-
* The CLI's own refresh session is NOT a Daemon resource (it's a
|
|
36
|
-
* `scope='cli'` refresh-session row with no Daemon FK), so it's invisible
|
|
37
|
-
* to `/api/daemon-resources`. We revoke it separately via
|
|
38
|
-
* `revokeCurrentRefreshToken()`.
|
|
39
|
-
*
|
|
40
|
-
* Ordering rationale for `--all-devices`:
|
|
41
|
-
* 1. `runAllDevices` is attempted first (remote sweep).
|
|
42
|
-
* 2. If it throws (network error, API unreachable), the error is captured
|
|
43
|
-
* and a warning is emitted — but execution CONTINUES to step 3.
|
|
44
|
-
* Rationale: a user who runs `logout --all-devices` after a security
|
|
45
|
-
* incident must not be left with live tokens on disk just because the
|
|
46
|
-
* API was momentarily unreachable. Local cleanup is always the safer
|
|
47
|
-
* default; re-running --all-devices later from a connected machine is
|
|
48
|
-
* idempotent and cleans up whatever the first attempt missed.
|
|
49
|
-
* 3. If the user explicitly declines the confirmation prompt inside
|
|
50
|
-
* `runAllDevices`, that path calls `this.exit(1)` which invokes
|
|
51
|
-
* `process.exit` — the try/catch is never reached, so local cleanup
|
|
52
|
-
* is correctly skipped (user said "abort").
|
|
53
|
-
*/
|
|
54
8
|
export default class Logout extends BaseCommand {
|
|
55
9
|
static description: string;
|
|
56
10
|
static flags: {
|
package/dist/commands/logout.js
CHANGED
|
@@ -40,6 +40,7 @@ const config_1 = require("../lib/config");
|
|
|
40
40
|
const first_party_harness_managed_1 = require("../lib/first-party-harness-managed");
|
|
41
41
|
const auth_storage_1 = require("../lib/auth-storage");
|
|
42
42
|
const daemonBroker_1 = require("../lib/daemonBroker");
|
|
43
|
+
const dedicated_service_1 = require("../lib/dedicated-service");
|
|
43
44
|
const refresh_1 = require("../lib/refresh");
|
|
44
45
|
const api_fetch_1 = require("../lib/api-fetch");
|
|
45
46
|
const cred_envelope_1 = require("../lib/cred-envelope");
|
|
@@ -97,6 +98,18 @@ function shouldSkipLocalLogout(input) {
|
|
|
97
98
|
* `process.exit` — the try/catch is never reached, so local cleanup
|
|
98
99
|
* is correctly skipped (user said "abort").
|
|
99
100
|
*/
|
|
101
|
+
/**
|
|
102
|
+
* True only on a Dedicated Runtime guest whose platform hand-off can still
|
|
103
|
+
* serve this CLI. A laptop daemon's island file ALSO advertises
|
|
104
|
+
* `access_token`, so the descriptor alone is not evidence of a platform-managed
|
|
105
|
+
* machine: on a laptop, gating on it alone told a person who had just signed
|
|
106
|
+
* out that "skrr keeps working here through the platform's managed hand-off"
|
|
107
|
+
* (OSK-12467). The guest markers are what make that sentence true.
|
|
108
|
+
*/
|
|
109
|
+
function platformManagedHandoff() {
|
|
110
|
+
return (0, dedicated_service_1.runningOnDedicatedRuntimeGuest)() && Boolean((0, daemonBroker_1.findBrokeredHandoffDescriptor)());
|
|
111
|
+
}
|
|
112
|
+
const SIGN_IN_AGAIN = 'To sign in again, run `skrr login`.';
|
|
100
113
|
class Logout extends base_command_1.BaseCommand {
|
|
101
114
|
static description = 'Sign out and clear locally-stored tokens. ' +
|
|
102
115
|
'--all-devices also revokes every registered daemon for this account.';
|
|
@@ -134,12 +147,12 @@ class Logout extends base_command_1.BaseCommand {
|
|
|
134
147
|
// `access_token`) there IS no local credential to clear — the CLI holds
|
|
135
148
|
// a per-process token redeemed over loopback and nothing is persisted.
|
|
136
149
|
// Say that honestly rather than reporting a logout that did not happen.
|
|
137
|
-
if ((
|
|
150
|
+
if (platformManagedHandoff()) {
|
|
138
151
|
this.log("This machine is signed in through the platform's managed hand-off; " +
|
|
139
152
|
'there is no local credential to clear.');
|
|
140
153
|
}
|
|
141
154
|
else {
|
|
142
|
-
this.log(
|
|
155
|
+
this.log(`Not signed in. Nothing to do. ${SIGN_IN_AGAIN}`);
|
|
143
156
|
}
|
|
144
157
|
return;
|
|
145
158
|
}
|
|
@@ -261,10 +274,15 @@ class Logout extends base_command_1.BaseCommand {
|
|
|
261
274
|
// OWN (an explicit `skrr login`); the machine's platform-managed access is
|
|
262
275
|
// untouched and skrr keeps working through it. Say both, so "Signed out."
|
|
263
276
|
// is not read as "this machine can no longer reach skrr" (OSK-12043).
|
|
264
|
-
|
|
277
|
+
// Anywhere else there is no such hand-off, so say how to sign back in
|
|
278
|
+
// instead (OSK-12467).
|
|
279
|
+
if (platformManagedHandoff()) {
|
|
265
280
|
this.log('Removed your own sign-in from this machine. ' +
|
|
266
281
|
"skrr keeps working here through the platform's managed hand-off.");
|
|
267
282
|
}
|
|
283
|
+
else {
|
|
284
|
+
this.log(SIGN_IN_AGAIN);
|
|
285
|
+
}
|
|
268
286
|
if (sweptLegacy.length > 0) {
|
|
269
287
|
this.log(`Also removed ${sweptLegacy.length} credential${sweptLegacy.length === 1 ? '' : 's'} left by the previous product identity:`);
|
|
270
288
|
for (const service of sweptLegacy)
|
|
@@ -93,7 +93,35 @@ export type AgenticStreamOptions = {
|
|
|
93
93
|
idleTerminalProbeMs?: number;
|
|
94
94
|
/** How long to keep trying to attach before reporting a detach. Default 30s. */
|
|
95
95
|
attachTimeoutMs?: number;
|
|
96
|
+
/**
|
|
97
|
+
* How long a prompt listens to (and throws away) input before it is shown.
|
|
98
|
+
* Default {@link TYPE_AHEAD_DISCARD_MS}. See {@link discardTypeAhead}.
|
|
99
|
+
*/
|
|
100
|
+
typeAheadDiscardMs?: number;
|
|
96
101
|
};
|
|
102
|
+
/**
|
|
103
|
+
* How long input arriving just before a prompt is shown is thrown away.
|
|
104
|
+
*
|
|
105
|
+
* Long enough for bytes already typed into the terminal to be read out of the
|
|
106
|
+
* kernel once stdin starts flowing (one event-loop turn in practice), short
|
|
107
|
+
* enough that nobody waits for it.
|
|
108
|
+
*/
|
|
109
|
+
export declare const TYPE_AHEAD_DISCARD_MS = 60;
|
|
110
|
+
/**
|
|
111
|
+
* Throw away whatever was typed BEFORE a prompt is shown (OSK-12468).
|
|
112
|
+
*
|
|
113
|
+
* A key pressed while the turn was still streaming sits in the stream's buffer
|
|
114
|
+
* or the terminal's, and the first `readline.question` read it as the answer:
|
|
115
|
+
* an owner typed `y` before any `[y/N]` was visible and approved a Bash command
|
|
116
|
+
* they had never seen. An approval is only consent to what was on the screen,
|
|
117
|
+
* so every prompt now starts from an empty input: bytes the stream already
|
|
118
|
+
* holds are read and dropped, then stdin flows into a discarding listener for
|
|
119
|
+
* `windowMs` so the terminal's own buffer is drained too. Raw mode is switched
|
|
120
|
+
* on for that window because a half-typed line (`y`, no Enter) is invisible to
|
|
121
|
+
* a read in canonical mode and would otherwise surface as soon as readline
|
|
122
|
+
* enters raw mode itself. A Ctrl-C in the discarded input is still honoured.
|
|
123
|
+
*/
|
|
124
|
+
export declare function discardTypeAhead(input: NodeJS.ReadableStream, windowMs?: number): Promise<void>;
|
|
97
125
|
/**
|
|
98
126
|
* What a permission request would DO, in one line a person can judge.
|
|
99
127
|
*
|
|
@@ -173,6 +201,13 @@ export type ProjectedToolEvent = {
|
|
|
173
201
|
* of the run. The pairing is unambiguous within a turn.
|
|
174
202
|
*/
|
|
175
203
|
export declare function projectDaemonToolEvents(envelopes: unknown[], namesByCall: Map<string, string>): ProjectedToolEvent[];
|
|
204
|
+
/**
|
|
205
|
+
* The exact output the daemon's Codex backend writes on a tool item that an
|
|
206
|
+
* approval declined (`codexToolItemEndOutput` in daemon/src/backends/codex.ts).
|
|
207
|
+
* Pinned against that source by agentic-stream.spec.ts, so a rewording there
|
|
208
|
+
* fails here instead of silently reverting these calls to `[tool failed]`.
|
|
209
|
+
*/
|
|
210
|
+
export declare const CODEX_DECLINED_TOOL_OUTPUTS: readonly string[];
|
|
176
211
|
/**
|
|
177
212
|
* The line printed when a tool call ends, in the words its status earns.
|
|
178
213
|
*
|
|
@@ -467,7 +502,21 @@ export declare class AgenticStreamClient {
|
|
|
467
502
|
* client is still showing it — the question twin of handlePermissionCancel.
|
|
468
503
|
*/
|
|
469
504
|
private handleQuestionCancel;
|
|
505
|
+
/** Prompts run one at a time: two readlines on one stdin would both read a line. */
|
|
506
|
+
private askQueue;
|
|
470
507
|
private ask;
|
|
508
|
+
/**
|
|
509
|
+
* One prompt, on a readline that exists only while the prompt is on screen.
|
|
510
|
+
*
|
|
511
|
+
* The interface used to be created at the first prompt and kept until the
|
|
512
|
+
* run closed, and it was created AFTER stdin had been accumulating whatever
|
|
513
|
+
* the person typed while the turn streamed — so the first question answered
|
|
514
|
+
* itself from that type-ahead (OSK-12468). Now the input is emptied first
|
|
515
|
+
* ({@link discardTypeAhead}), only then is the question shown, and the
|
|
516
|
+
* interface is closed with the answer, so nothing typed between two prompts
|
|
517
|
+
* can answer the second one either.
|
|
518
|
+
*/
|
|
519
|
+
private askNow;
|
|
471
520
|
/**
|
|
472
521
|
* True the first time this (kind, identity) pair is seen; false afterwards.
|
|
473
522
|
* The identity is the event's OWN — a tool-use id, a message id — never a
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.AgenticStreamClient = exports.USER_CONNECTION_CAP_CODE = exports.NON_INTERACTIVE_QUESTION_DISMISSAL = void 0;
|
|
3
|
+
exports.AgenticStreamClient = exports.USER_CONNECTION_CAP_CODE = exports.CODEX_DECLINED_TOOL_OUTPUTS = exports.TYPE_AHEAD_DISCARD_MS = exports.NON_INTERACTIVE_QUESTION_DISMISSAL = void 0;
|
|
4
4
|
exports.writeJsonLine = writeJsonLine;
|
|
5
|
+
exports.discardTypeAhead = discardTypeAhead;
|
|
5
6
|
exports.describePermissionTarget = describePermissionTarget;
|
|
6
7
|
exports.envelopesText = envelopesText;
|
|
7
8
|
exports.envelopeKey = envelopeKey;
|
|
@@ -54,6 +55,59 @@ exports.NON_INTERACTIVE_QUESTION_DISMISSAL = 'No answer: this turn was started n
|
|
|
54
55
|
function writeJsonLine(output, value) {
|
|
55
56
|
output.write(`${JSON.stringify(value) ?? 'null'}\n`);
|
|
56
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* How long input arriving just before a prompt is shown is thrown away.
|
|
60
|
+
*
|
|
61
|
+
* Long enough for bytes already typed into the terminal to be read out of the
|
|
62
|
+
* kernel once stdin starts flowing (one event-loop turn in practice), short
|
|
63
|
+
* enough that nobody waits for it.
|
|
64
|
+
*/
|
|
65
|
+
exports.TYPE_AHEAD_DISCARD_MS = 60;
|
|
66
|
+
/**
|
|
67
|
+
* Throw away whatever was typed BEFORE a prompt is shown (OSK-12468).
|
|
68
|
+
*
|
|
69
|
+
* A key pressed while the turn was still streaming sits in the stream's buffer
|
|
70
|
+
* or the terminal's, and the first `readline.question` read it as the answer:
|
|
71
|
+
* an owner typed `y` before any `[y/N]` was visible and approved a Bash command
|
|
72
|
+
* they had never seen. An approval is only consent to what was on the screen,
|
|
73
|
+
* so every prompt now starts from an empty input: bytes the stream already
|
|
74
|
+
* holds are read and dropped, then stdin flows into a discarding listener for
|
|
75
|
+
* `windowMs` so the terminal's own buffer is drained too. Raw mode is switched
|
|
76
|
+
* on for that window because a half-typed line (`y`, no Enter) is invisible to
|
|
77
|
+
* a read in canonical mode and would otherwise surface as soon as readline
|
|
78
|
+
* enters raw mode itself. A Ctrl-C in the discarded input is still honoured.
|
|
79
|
+
*/
|
|
80
|
+
async function discardTypeAhead(input, windowMs = exports.TYPE_AHEAD_DISCARD_MS) {
|
|
81
|
+
const tty = input;
|
|
82
|
+
const canSetRaw = Boolean(tty.isTTY) && typeof tty.setRawMode === 'function';
|
|
83
|
+
const wasRaw = canSetRaw ? Boolean(tty.isRaw) : false;
|
|
84
|
+
let sawInterrupt = false;
|
|
85
|
+
const drop = (chunk) => {
|
|
86
|
+
const text = typeof chunk === 'string' ? chunk : Buffer.isBuffer(chunk) ? chunk.toString() : '';
|
|
87
|
+
if (text.includes('\u0003'))
|
|
88
|
+
sawInterrupt = true;
|
|
89
|
+
};
|
|
90
|
+
try {
|
|
91
|
+
if (canSetRaw && !wasRaw)
|
|
92
|
+
tty.setRawMode(true);
|
|
93
|
+
const readable = input;
|
|
94
|
+
if (typeof readable.read === 'function') {
|
|
95
|
+
for (let chunk = readable.read(); chunk !== null; chunk = readable.read())
|
|
96
|
+
drop(chunk);
|
|
97
|
+
}
|
|
98
|
+
input.on('data', drop);
|
|
99
|
+
input.resume();
|
|
100
|
+
await new Promise((resolve) => setTimeout(resolve, Math.max(0, windowMs)));
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
input.removeListener('data', drop);
|
|
104
|
+
input.pause();
|
|
105
|
+
if (canSetRaw && !wasRaw)
|
|
106
|
+
tty.setRawMode(false);
|
|
107
|
+
}
|
|
108
|
+
if (sawInterrupt)
|
|
109
|
+
process.kill(process.pid, 'SIGINT');
|
|
110
|
+
}
|
|
57
111
|
const PERMISSION_DETAIL_MAX = 400;
|
|
58
112
|
/**
|
|
59
113
|
* What a permission request would DO, in one line a person can judge.
|
|
@@ -244,12 +298,48 @@ function projectDaemonToolEvents(envelopes, namesByCall) {
|
|
|
244
298
|
...(call ? { toolUseId: call } : {}),
|
|
245
299
|
...(name ? { toolName: name } : {}),
|
|
246
300
|
...(ev.output !== undefined ? { toolResponse: ev.output } : {}),
|
|
247
|
-
...(typeof ev.status === 'string' ? { status: ev
|
|
301
|
+
...(typeof ev.status === 'string' ? { status: daemonToolEndStatus(ev) } : {}),
|
|
248
302
|
});
|
|
249
303
|
}
|
|
250
304
|
}
|
|
251
305
|
return out;
|
|
252
306
|
}
|
|
307
|
+
/**
|
|
308
|
+
* The exact output the daemon's Codex backend writes on a tool item that an
|
|
309
|
+
* approval declined (`codexToolItemEndOutput` in daemon/src/backends/codex.ts).
|
|
310
|
+
* Pinned against that source by agentic-stream.spec.ts, so a rewording there
|
|
311
|
+
* fails here instead of silently reverting these calls to `[tool failed]`.
|
|
312
|
+
*/
|
|
313
|
+
exports.CODEX_DECLINED_TOOL_OUTPUTS = [
|
|
314
|
+
'Declined: the approval was refused, so the command did not run.',
|
|
315
|
+
'Declined: the approval was refused, so the edit was not applied.',
|
|
316
|
+
];
|
|
317
|
+
/**
|
|
318
|
+
* A daemon `tool-call-end` status, with a Codex decline read as `denied`.
|
|
319
|
+
*
|
|
320
|
+
* OSK-12480 — a Codex `exec_command` a person or a policy refused ends as an
|
|
321
|
+
* app-server item with status `declined`, which the daemon writes on the wire
|
|
322
|
+
* as `failed`: the envelope's status vocabulary (`ToolCallEndStatusSchema`,
|
|
323
|
+
* enforced at the server's persistence boundary) has no `denied`, so the
|
|
324
|
+
* daemon cannot say it there without breaking every server that predates it.
|
|
325
|
+
* What it does say is the output — a sentence it authors for exactly that
|
|
326
|
+
* case, and never for a call that ran. So the stream printed `[tool failed]`
|
|
327
|
+
* for a command that never started, where the daemon's Claude-style path
|
|
328
|
+
* (OSK-12469) prints `[tool denied] … (it did not run)`.
|
|
329
|
+
*
|
|
330
|
+
* Only that exact output upgrades a `failed`: a genuine failure keeps its
|
|
331
|
+
* status, and so does a decline Codex gave its own output (the status quo,
|
|
332
|
+
* never a misclassification).
|
|
333
|
+
*/
|
|
334
|
+
function daemonToolEndStatus(ev) {
|
|
335
|
+
const status = ev.status;
|
|
336
|
+
if (status === 'failed' &&
|
|
337
|
+
typeof ev.output === 'string' &&
|
|
338
|
+
exports.CODEX_DECLINED_TOOL_OUTPUTS.includes(ev.output)) {
|
|
339
|
+
return 'denied';
|
|
340
|
+
}
|
|
341
|
+
return status;
|
|
342
|
+
}
|
|
253
343
|
/**
|
|
254
344
|
* The line printed when a tool call ends, in the words its status earns.
|
|
255
345
|
*
|
|
@@ -266,6 +356,13 @@ function describeToolEnd(toolName, status) {
|
|
|
266
356
|
return `[tool failed] ${name}`;
|
|
267
357
|
case 'cancelled':
|
|
268
358
|
return `[tool cancelled] ${name}`;
|
|
359
|
+
// OSK-12469 — a call whose approval was refused never ran. It printed
|
|
360
|
+
// `[tool completed]` just before the line saying it was denied; a call
|
|
361
|
+
// whose ask expired, or whose turn a deploy paused, did the same.
|
|
362
|
+
case 'denied':
|
|
363
|
+
return `[tool denied] ${name} (it did not run)`;
|
|
364
|
+
case 'not_run':
|
|
365
|
+
return `[tool not run] ${name}`;
|
|
269
366
|
case 'unreported':
|
|
270
367
|
return `[tool result not reported] ${name} (the harness never said how it ended; closed when the session ended)`;
|
|
271
368
|
default:
|
|
@@ -1151,26 +1248,54 @@ class AgenticStreamClient {
|
|
|
1151
1248
|
promptClosed: Boolean(open),
|
|
1152
1249
|
});
|
|
1153
1250
|
}
|
|
1251
|
+
/** Prompts run one at a time: two readlines on one stdin would both read a line. */
|
|
1252
|
+
askQueue = Promise.resolve();
|
|
1154
1253
|
ask(prompt, signal) {
|
|
1254
|
+
const next = this.askQueue.then(() => this.askNow(prompt, signal));
|
|
1255
|
+
this.askQueue = next.catch(() => undefined);
|
|
1256
|
+
return next;
|
|
1257
|
+
}
|
|
1258
|
+
/**
|
|
1259
|
+
* One prompt, on a readline that exists only while the prompt is on screen.
|
|
1260
|
+
*
|
|
1261
|
+
* The interface used to be created at the first prompt and kept until the
|
|
1262
|
+
* run closed, and it was created AFTER stdin had been accumulating whatever
|
|
1263
|
+
* the person typed while the turn streamed — so the first question answered
|
|
1264
|
+
* itself from that type-ahead (OSK-12468). Now the input is emptied first
|
|
1265
|
+
* ({@link discardTypeAhead}), only then is the question shown, and the
|
|
1266
|
+
* interface is closed with the answer, so nothing typed between two prompts
|
|
1267
|
+
* can answer the second one either.
|
|
1268
|
+
*/
|
|
1269
|
+
async askNow(prompt, signal) {
|
|
1155
1270
|
// A prompt raised after close has nobody to answer it, and re-creating the
|
|
1156
1271
|
// interface here would resurrect stdin for a run that is already over.
|
|
1157
1272
|
if (this.closed || signal?.aborted)
|
|
1158
|
-
return
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1273
|
+
return '';
|
|
1274
|
+
const input = this.options.input ?? process.stdin;
|
|
1275
|
+
await discardTypeAhead(input, this.options.typeAheadDiscardMs ?? exports.TYPE_AHEAD_DISCARD_MS);
|
|
1276
|
+
if (this.closed || signal?.aborted)
|
|
1277
|
+
return '';
|
|
1278
|
+
const rl = (0, node_readline_1.createInterface)({ input, output: process.stderr });
|
|
1279
|
+
this.readline = rl;
|
|
1280
|
+
try {
|
|
1281
|
+
return await new Promise((resolve) => {
|
|
1282
|
+
// Closed under us (the run ended): nobody is left to answer.
|
|
1283
|
+
rl.once('close', () => resolve(''));
|
|
1284
|
+
if (!signal) {
|
|
1285
|
+
rl.question(prompt, resolve);
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
// readline does not call back for a question aborted by its signal, so
|
|
1289
|
+
// the abort settles the prompt itself.
|
|
1290
|
+
signal.addEventListener('abort', () => resolve(''), { once: true });
|
|
1291
|
+
rl.question(prompt, { signal }, resolve);
|
|
1163
1292
|
});
|
|
1164
1293
|
}
|
|
1165
|
-
|
|
1166
|
-
|
|
1294
|
+
finally {
|
|
1295
|
+
if (this.readline === rl)
|
|
1296
|
+
this.readline = null;
|
|
1297
|
+
rl.close();
|
|
1167
1298
|
}
|
|
1168
|
-
return new Promise((resolve) => {
|
|
1169
|
-
// readline does not call back for a question aborted by its signal, so
|
|
1170
|
-
// the abort settles the prompt itself.
|
|
1171
|
-
signal.addEventListener('abort', () => resolve(''), { once: true });
|
|
1172
|
-
this.readline.question(prompt, { signal }, resolve);
|
|
1173
|
-
});
|
|
1174
1299
|
}
|
|
1175
1300
|
/**
|
|
1176
1301
|
* True the first time this (kind, identity) pair is seen; false afterwards.
|
package/dist/lib/api-fetch.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type ResolvedCredential } from './credential-resolver';
|
|
2
|
+
import { type DaemonBrokerFailure } from './daemonBroker';
|
|
2
3
|
export interface ApiFetchOptions {
|
|
3
4
|
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
4
5
|
body?: unknown;
|
|
@@ -36,6 +37,14 @@ export declare class ApiFetchError extends Error {
|
|
|
36
37
|
* attributed by guesswork — see `node-adapter.ts`.
|
|
37
38
|
*/
|
|
38
39
|
readonly requestId?: string;
|
|
40
|
+
/**
|
|
41
|
+
* Set on a 401 when this CLI's own refresh was refused AND the local
|
|
42
|
+
* daemon, asked for a hand-off, refused too (OSK-12474). The daemon's reason
|
|
43
|
+
* decides the repair, so the 401 message carries it rather than a bare
|
|
44
|
+
* "Run `skrr login`" (OSK-12149, OSK-12151); `describeBrokerRefusal` turns
|
|
45
|
+
* it into words. Absent when the daemon was never asked.
|
|
46
|
+
*/
|
|
47
|
+
brokerFailure?: DaemonBrokerFailure;
|
|
39
48
|
constructor(message: string, status: number, body?: string, code?: string, method?: string, requestId?: string);
|
|
40
49
|
}
|
|
41
50
|
/**
|
|
@@ -44,6 +53,8 @@ export declare class ApiFetchError extends Error {
|
|
|
44
53
|
* `err.status` / `err.code`.
|
|
45
54
|
*/
|
|
46
55
|
export declare function apiFetch<T>(pathOrUrl: string, opts?: ApiFetchOptions): Promise<T>;
|
|
56
|
+
/** @internal test seam — forget this process's daemon hand-off attempt. */
|
|
57
|
+
export declare function __resetDaemonHandoffFallbackForTest(): void;
|
|
47
58
|
/**
|
|
48
59
|
* True when the current process has a persisted refresh-capable session
|
|
49
60
|
* (`skrr login` has been run and survived). Used by CI-token commands
|