firedrill-run 3.0.0 → 3.0.1

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/README.md CHANGED
@@ -9,19 +9,17 @@ Use the CLI yourself, or let a coding agent operate it for you. Local execution
9
9
  is not an operating-system sandbox. Approved commands run with your own user
10
10
  permissions.
11
11
 
12
- **Release candidate:** the proposed public package is `firedrill-run@3.0.0`.
13
- It is not published or approved for public activation yet. The existing
14
- unversioned package resolves to the older product. Do not use `@latest` to
15
- install this candidate. Once the owner approves and publishes this release,
16
- the intended commands are:
12
+ ## Get started
13
+
14
+ Use this version explicitly while the unversioned package still points to the
15
+ previous platform:
17
16
 
18
17
  ```sh
19
- npx --yes firedrill-run@3.0.0 init
20
- npx --yes firedrill-run@3.0.0 --help
18
+ npx firedrill-run@3.0.1 init
19
+ npx firedrill-run@3.0.1 --help
21
20
  ```
22
21
 
23
- Until then, use only an explicitly supplied private candidate tarball.
24
- `firedrill --help` of that exact installed candidate is authoritative.
22
+ For automation, `firedrill --help` and `--json` describe the installed version.
25
23
 
26
24
  ## Requirements
27
25
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema": "firedrill.cli-assembly.v1",
3
3
  "package": "firedrill-run",
4
- "version": "3.0.0",
4
+ "version": "3.0.1",
5
5
  "entry": "cli/src/main.js",
6
6
  "helpers": [
7
7
  "cli/runtime/source_snapshot.py",
@@ -130,6 +130,15 @@ export async function login(options) {
130
130
  throw new CliError('authorization_exchange_uncertain', 'The original token exchange was interrupted and cannot be safely repeated. Review Account access in the browser. Clear the local attempt with logout --local-only before starting another.', 2);
131
131
  if (!pending) {
132
132
  const grant = await jsonRequest(new URL(config.device_authorization_endpoint), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ client_id: config.client_id }), signal: options.signal });
133
+ if (grant.status === 429) {
134
+ // No grant exists yet. Report a definitive refusal without creating a
135
+ // second grant or replaying an unknown device-authorization POST.
136
+ const wait = grant.retryAfterMs;
137
+ const advice = wait !== undefined && Number.isFinite(wait) && wait <= 3_600_000
138
+ ? ` Wait at least ${Math.max(1, Math.ceil(wait / 1000))} seconds before starting sign-in again.`
139
+ : ' Wait before starting sign-in again; do not repeatedly restart it.';
140
+ throw new CliError('device_authorization_rate_limited', 'Browser sign-in is temporarily busy.' + advice, 2);
141
+ }
133
142
  if (grant.status !== 200)
134
143
  throw new CliError('device_authorization_failed', 'Browser sign-in could not be started.');
135
144
  const value = grant.body;
@@ -166,6 +175,7 @@ export async function login(options) {
166
175
  : 'Run the same command to resume if interrupted.'}\n`);
167
176
  if (launchBrowser)
168
177
  await openBrowser(pending.verification_url);
178
+ let rateRefusals = 0;
169
179
  while (Date.now() < pending.expires_at * 1000) {
170
180
  await sleep(Math.min(Math.max(0, pending.next_poll_at - Date.now()), Math.max(0, pending.expires_at * 1000 - Date.now())), undefined, { signal: options.signal });
171
181
  options.signal.throwIfAborted();
@@ -183,7 +193,38 @@ export async function login(options) {
183
193
  return finishAuthorization(saved);
184
194
  }
185
195
  const error = result.body && typeof result.body === 'object' ? result.body.error : undefined;
196
+ if (result.status === 429) {
197
+ // A complete, parsed HTTP refusal is distinct from transport/parse
198
+ // failure or a response that may contain exchanged credentials. Those
199
+ // uncertain cases keep the polling marker and are never replayed.
200
+ const body = result.body;
201
+ if (!body || typeof body !== 'object' || Array.isArray(body)
202
+ || ['access_token', 'refresh_token', 'id_token', 'user'].some(key => key in body))
203
+ throw new CliError('authorization_exchange_uncertain', 'The authorization response was not confirmed. Review Account access before clearing the original local attempt. No exchange was retried.', 2);
204
+ rateRefusals += 1;
205
+ const interval = error === 'slow_down' ? Math.min(3600, pending.interval + 5) : pending.interval;
206
+ const backoff = Math.min(60_000, 5_000 * 2 ** Math.min(rateRefusals - 1, 4));
207
+ const minimumWait = Math.max(interval * 1000, result.retryAfterMs ?? backoff);
208
+ // Never retry earlier than Retry-After or extend the original grant.
209
+ // An advertised wait beyond that lifetime reaches expiry, not a retry.
210
+ const deadline = pending.expires_at * 1000;
211
+ const nextPollAt = Math.min(deadline, Date.now() + minimumWait + Math.floor(Math.random() * 1000));
212
+ pending = { ...pending, state: 'waiting', interval, next_poll_at: nextPollAt };
213
+ await savePendingDevice(pending, options.signal);
214
+ const waitSeconds = Math.max(1, Math.ceil((nextPollAt - Date.now()) / 1000));
215
+ if (options.json)
216
+ process.stdout.write(JSON.stringify({ type: 'authorization_waiting', authorization_id: pending.id,
217
+ reason: 'rate_limited', retry_in_seconds: nextPollAt < deadline ? waitSeconds : null,
218
+ expires_in: Math.max(0, Math.ceil((deadline - Date.now()) / 1000)),
219
+ same_authorization: true, new_device_grant: false }) + '\n');
220
+ else
221
+ process.stderr.write(nextPollAt < deadline
222
+ ? `Sign-in is temporarily busy. Waiting ${waitSeconds} seconds before checking the same authorization again.\n`
223
+ : 'Sign-in is temporarily busy. Its required wait reaches the original authorization expiry; no new authorization will be started automatically.\n');
224
+ continue;
225
+ }
186
226
  if (error === 'authorization_pending' || error === 'slow_down') {
227
+ rateRefusals = 0;
187
228
  // RFC 8628: slow_down permanently adds 5 seconds to the polling interval.
188
229
  const interval = error === 'slow_down' ? Math.min(3600, pending.interval + 5) : pending.interval;
189
230
  pending = { ...pending, state: 'waiting', interval, next_poll_at: Date.now() + interval * 1000 };
@@ -745,12 +745,17 @@ export async function main() {
745
745
  if (args[0] === 'init') {
746
746
  if (Object.keys(parsed.values).some(key => !['json', 'help', 'workspace', 'no-browser', 'open'].includes(key)))
747
747
  throw new CliError('arguments_invalid', 'Use firedrill init with optional --session-only or --persistent, --workspace <id>, --no-browser, --open and --json.', 2);
748
- await initCommand(args.slice(1), { json, signal: controller.signal, workspace: parsed.values.workspace,
748
+ const initialization = await initCommand(args.slice(1), { json, signal: controller.signal, workspace: parsed.values.workspace,
749
749
  openBrowser: !parsed.values['no-browser'], explicitOpen, mode: credentialMode.mode }, emit);
750
750
  if (companion) {
751
751
  // Session-only credentials live only in this process: keep serving until
752
752
  // a signal or logout (companion shutdown) stops it.
753
- emit('companion_running', { endpoint_ready: true, mode: 'session' }, 'Session-only companion running. Keep this terminal open; other firedrill commands use it. Stop it with Ctrl-C or firedrill logout.');
753
+ emit('companion_running', { endpoint_ready: true, mode: 'session', initialization_state: initialization.state,
754
+ initialization_complete: initialization.state === 'ready',
755
+ command_phase_complete: true,
756
+ workspace_id: initialization.state === 'ready' ? initialization.workspace_id : null,
757
+ process_lifetime: 'until_logout_or_signal',
758
+ next_command: initialization.state === 'workspace_pending' ? 'firedrill workspace list --session-only --json' : null }, 'Session-only companion running. Keep this terminal open; other firedrill commands use it. Stop it with Ctrl-C or firedrill logout.');
754
759
  if (!controller.signal.aborted)
755
760
  await once(controller.signal, 'abort');
756
761
  emit('companion_stopping', { reason: signalled ? 'signal' : 'shutdown' }, 'Stopping the companion. In-memory credentials are erased on close.');
@@ -1,5 +1,5 @@
1
1
  import { api, login } from '../auth.js';
2
- import { CliError } from '../http.js';
2
+ import { APP_ORIGIN, CliError } from '../http.js';
3
3
  import { terminalText } from '../prompts.js';
4
4
  import { selectedWorkspace } from '../world-access.js';
5
5
  import { machineReport, machineText } from './status.js';
@@ -40,7 +40,41 @@ export async function initCommand(args, options, emit) {
40
40
  throw error;
41
41
  session = await login({ json: options.json, open: options.openBrowser, explicitOpen: options.explicitOpen, signal: options.signal });
42
42
  }
43
- const scope = await selectedWorkspace({ workspace: options.workspace, interactive, signal: options.signal, openBrowser });
43
+ let scope;
44
+ try {
45
+ scope = await selectedWorkspace({ workspace: options.workspace, interactive, signal: options.signal, openBrowser });
46
+ }
47
+ catch (error) {
48
+ // Device authorization already succeeded. A durable workspace discovery or
49
+ // explicit setup step must not erase the only copy of that signed-in session.
50
+ // Only session-only init owns a companion lifetime; persistent commands and
51
+ // authorization/transport/cancellation failures retain their normal errors.
52
+ if (mode !== 'session' || options.signal.aborted || !(error instanceof CliError)
53
+ || !['workspace_discovery_pending', 'workspace_setup_required'].includes(error.code))
54
+ throw error;
55
+ const reason = error.code === 'workspace_discovery_pending' ? 'workspace_discovery_pending' : 'workspace_setup_required';
56
+ emit('init_pending', {
57
+ state: 'workspace_pending', reason, authenticated: true, initialization_complete: false,
58
+ account: { id: session.user.id, email: session.user.email }, workspace_id: null, credential_mode: 'session',
59
+ companion_required: true, process_lifetime: 'until_logout_or_signal',
60
+ next_command: 'firedrill workspace list --session-only --json',
61
+ select_command_template: 'firedrill workspace use <workspace-id> --session-only --json',
62
+ status_command: 'firedrill status --session-only --json',
63
+ ...(reason === 'workspace_setup_required' ? { browser_url: new URL('/onboarding', APP_ORIGIN).href,
64
+ action_required: 'create_or_select_workspace_with_person_approval' } : {}),
65
+ uploaded: false, model_called: false,
66
+ }, [
67
+ `Signed in as ${terminalText(session.user.email)}.`,
68
+ reason === 'workspace_discovery_pending' ? 'Workspace access is still being checked.' : 'A workspace still needs to be created or selected.',
69
+ 'Initialization is not complete. Keep this terminal open to retain the session-only sign-in.',
70
+ 'In another terminal, run firedrill workspace list --session-only, then firedrill workspace use <workspace-id> --session-only.',
71
+ 'Use only a workspace returned for this account. Normal organization authorization and person approvals still apply.',
72
+ ...(reason === 'workspace_setup_required' ? [`Workspace setup: ${new URL('/onboarding', APP_ORIGIN).href}`] : []),
73
+ 'After selecting it, firedrill status --session-only shows the current account, workspace and machine.',
74
+ 'Do not start another init while this companion is running. Stop with Ctrl-C or firedrill logout.',
75
+ ].join('\n'));
76
+ return { state: 'workspace_pending', reason };
77
+ }
44
78
  const machine = await machineReport(options.signal);
45
79
  emit('init', {
46
80
  account: { id: session.user.id, email: session.user.email }, workspace_id: scope.workspace, credential_mode: mode, machine,
@@ -58,4 +92,5 @@ export async function initCommand(args, options, emit) {
58
92
  ...NEXT.map((step, index) => ` ${index + 1}. ${step.command}\n ${step.note}`),
59
93
  'Nothing was uploaded and no model was called.',
60
94
  ].join('\n'));
95
+ return { state: 'ready', workspace_id: scope.workspace };
61
96
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "firedrill-run",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
4
4
  "description": "Local AI agent simulations with cloud-hosted worlds and tools",
5
5
  "type": "module",
6
6
  "bin": {