@enter-pro/enter-cli 0.4.3 → 0.4.4

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
@@ -31,7 +31,7 @@ unset the variable in your shell.
31
31
  enter-cli --output json thread chat PROJECT_ID --file requirement.txt
32
32
  enter-cli --output json thread wait PROJECT_ID --task-id TASK_ID --timeout 10
33
33
  enter-cli --output json thread status PROJECT_ID --task-id TASK_ID
34
- enter-cli thread watch PROJECT_ID --task-id TASK_ID --timeout 60
34
+ enter-cli thread wait PROJECT_ID --task-id TASK_ID --stream --timeout 0
35
35
  ```
36
36
 
37
37
  `chat --stdin` also accepts multiline input. Submission returns immediately with
@@ -41,16 +41,16 @@ can be submitted while Enter works; acceptance is not completion.
41
41
  | Command | Output and default lifetime |
42
42
  | --- | --- |
43
43
  | `status` | One JSON/YAML snapshot, 30-second deadline |
44
- | `wait` | One result on a card, terminal state or timeout; 10 seconds |
45
- | `watch` | Changed snapshots as NDJSON, then `type: result`; 60 seconds |
44
+ | `wait --stream` | One continuous observer with batched NDJSON; unlimited by default, explicit --timeout bounds lifetime |
45
+ | `wait` | One result on new narrative progress, a card, terminal state or timeout; 10 seconds |
46
+ | `watch` | Changed snapshots as NDJSON, then `type: result`; 60 seconds; unlimited only with explicit `--timeout 0` |
46
47
 
47
- Use one observer per task. In interactive agents, use the returned short wait,
48
- handle incoming user messages, then continue the same task. Normal observation
49
- timeout is not a reason to increase the wait or resubmit. Background watch is
50
- appropriate only with completion notifications and non-blocking output collection;
51
- do not immediately block on the job for minutes. CLI lifetime and host job-wait
52
- limits are separate upper bounds, not measured elapsed time. Hosts own message
53
- scheduling: CLI events cannot make a blocked host process new user input.
48
+ Use one observer per task. Hosts that buffer shell output use bounded `wait`;
49
+ Helix uses `--timeout 10` to return control regularly. On timeout, first handle
50
+ incoming user messages, then report the actual state and continue the returned command for the same task,
51
+ without sleep, resubmission or repeated permission questions. Hosts that consume
52
+ stdout incrementally should use `wait --stream`; unchanged successful observations emit a
53
+ heartbeat every max(30 seconds, progress interval). Hosts own scheduling and interruption.
54
54
 
55
55
  `--task-id` follows exactly the submitted task, including queue time. `--turn N`
56
56
  selects a fixed turn instead; without either, observation pins the first turn.
@@ -70,8 +70,8 @@ messages and the resulting change, and do not substitute the newest turn.
70
70
 
71
71
  `project create --wait` uses the same observer and requires a matching successful
72
72
  build. For existing tasks use `--require-build`; stale builds cannot satisfy it.
73
- `build_matches_turn` checks commit identity. `status/wait` retain full metadata;
74
- `--compact` selects monitoring fields. `watch` is compact unless `--full` is set.
73
+ `build_matches_turn` checks commit identity. `status/wait/watch` return monitoring fields by default, excluding usage and unrelated metadata.
74
+ Use `--full` for detailed sanitized metadata; `--compact` remains an explicit alias for the default.
75
75
  The additive `workflow` projection supplies `state`, `task`, `next_action`,
76
76
  `observation`, build matching and action descriptors; full card details remain in
77
77
  `actions`. `use_existing_authorization` does not grant new permission.
@@ -90,14 +90,12 @@ separately; do not replace Enter's validation with source keyword scans.
90
90
 
91
91
  ## Transport and errors
92
92
 
93
- Observation uses authenticated WebSocket events, reconnects with `last_event_id`,
94
- deduplicates replay, and re-reads authoritative HTTP state. Healthy streams have a
95
- 30-second safety refresh; unavailable streams fall back to 2-second HTTP checks.
96
- Text/argument deltas do not trigger full queries; card persistence gets a short
97
- reconciliation window. `--transport poll` forces HTTP, `--cursor` resumes events,
98
- and `transport/transport_reason/cursor` identify progress. Stream 401/403 fails explicitly;
99
- unsupported endpoints fall back without repeated reconnects. WebSockets respect
100
- proxy variables and `NO_PROXY`; `NODE_USE_ENV_PROXY=0` disables their proxy use.
93
+ Observation defaults to HTTP polling every 2 seconds after each read. Each
94
+ snapshot query is bounded to 30 seconds. No WebSocket is opened by default.
95
+ `--transport auto` opts in to WebSocket events with HTTP fallback; 401/403 on the
96
+ stream stops reconnection but leaves state observation on HTTP. Raw message
97
+ follow still fails with STREAM_AUTH_ERROR. HTTP authentication failures stop
98
+ observation. WebSockets respect proxy variables and NO_PROXY.
101
99
 
102
100
  `thread messages PROJECT_ID --follow --timeout 60 --cursor EVENT_ID` emits raw
103
101
  NDJSON events with diagnostics on stderr. It supports `--max-events`, crosses turn
@@ -142,7 +140,7 @@ npm ci
142
140
  npm test
143
141
  npm run build
144
142
  npm pack
145
- npm run install:hosts -- --package /absolute/path/enter-pro-enter-cli-0.4.3.tgz
143
+ npm run install:hosts -- --package /absolute/path/enter-pro-enter-cli-0.4.4.tgz
146
144
  ```
147
145
 
148
146
  Tests use loopback HTTP/WebSocket fixtures and dummy credentials, not real cloud
@@ -165,3 +163,87 @@ Scenarios: `plan`, `cloud`, `ai`, `secret`, `questions`, `subscription`, `runnin
165
163
  unsupported routes fail, and request bodies/secrets are not retained. Only use
166
164
  fake values. `.env.integration.example` lists real-test variables; its ignored
167
165
  `.env.integration.local` counterpart is never loaded automatically.
166
+
167
+ ### Agent host integration
168
+
169
+ All observers return `progress` from the selected task's HTTP message history:
170
+ `source: assistant_messages`, `available`, a scoped `revision`, and the latest
171
+ assistant text in `messages` (up to 1600 characters, marked `truncated` if clipped).
172
+ Reasoning and raw tool arguments are excluded. `observed_at` timestamps the last
173
+ successful state read; `emitted_at` timestamps watch output, including stale results.
174
+ A narrative read failure is separate from authoritative task/action status.
175
+
176
+ `wait` batches ordinary narrative for 10 seconds by default, keeping the latest text,
177
+ and returns early when that interval has elapsed and progress changed.
178
+ `--progress-interval <seconds>` controls batching; `0` opts into every change.
179
+ Actions, terminal states and errors bypass batching. The overall `--timeout` still
180
+ bounds the call and returns the latest observation even before the interval elapses.
181
+ `status` remains an immediate read; no phase is guessed from narrative keywords. `workflow.next_action=report_progress` means relay the text first, then `after_reporting=observe`. Exit 0 means an observation result, not build completion. Execute the returned `wait_command`, including
182
+ `--after-progress`, to wait for the next revision without replaying the same update.
183
+ `watch` uses the same interval for ordinary narrative/metadata updates; state changes
184
+ and final results are emitted immediately.
185
+ These are latest-progress snapshots, not a lossless replay of every message.
186
+
187
+ The host agent relays the supplied text, forwards questions, and executes the next
188
+ command; it need not reason over logs or infer development phases. Relay new progress
189
+ proactively, suppress repeated revisions/heartbeats, and answer user questions from
190
+ the last observation before refreshing. Background hosts with only completion notifications fall back to bounded `wait`: batched progress ends the job and triggers that notification. Merely being able to read running stdout is insufficient for automatic `watch` delivery. For background jobs, read output nonblockingly
191
+ or with a short bounded wait; a five-minute blocking job-output call defeats background
192
+ execution. The CLI cannot schedule the host agent or interrupt its tool calls.
193
+
194
+ Both `wait` and `watch` use HTTP by default. The CLI performs the polling;
195
+ do not generate `sleep && enter-cli` or a loop of `thread status` commands.
196
+
197
+ For hosts that return shell output only after the command exits, use `wait`.
198
+ For the current Helix integration, use a short 10-second observation window
199
+ to return control for incoming messages:
200
+
201
+ ```bash
202
+ enter-cli --output json thread wait PROJECT_ID --task-id TASK_ID --timeout 10
203
+ ```
204
+
205
+ The CLI and Helix guidance both default to 10 seconds. A shell deadline alone
206
+ does not guarantee responsiveness. On exit 2, handle incoming messages, report
207
+ the returned Enter stage/status and execute the returned `wait_command` to keep observing.
208
+ It preserves the task or pinned turn, chat, build requirement and observation
209
+ timeout. Do not ask the user whether to continue, recreate the project or
210
+ resubmit the build. A timeout ends observation, not the remote task. On
211
+ `blocked`, forward the action and follow the continuation after answering;
212
+ on `completed` or `failed`, consume the result.
213
+
214
+ For hosts that notify the agent when incremental stdout arrives without blocking user interaction,
215
+ use `watch_command` (60 seconds by default). Only explicitly select `--timeout 0`
216
+ when the host can manage an unlimited background observer.
217
+ `snapshot` contains actual Enter task/turn state, `heartbeat` confirms a
218
+ successful observation without a state change, and `result` ends observation.
219
+ This is stage/status progress, not generated percentages or a raw model-token
220
+ log. Each snapshot query has a 30-second deadline. SIGINT/SIGTERM stop only
221
+ the observer (130/143); the host owns background execution and reconnection.
222
+
223
+ The current Helix sandbox adapter buffers stdout until command completion;
224
+ therefore an indefinitely running `watch` is not its default integration path.
225
+ Helix colleagues only need to update the actual sandbox CLI and the agent's
226
+ usage instructions for the bounded `wait` path, then verify it in a fresh
227
+ sandbox. Continuous in-flight display requires host-side incremental output
228
+ support; local CLI tests do not prove that integration.
229
+
230
+ State and pending-action reads run concurrently to avoid spending the observation
231
+ budget on sequential independent requests. Failed turns do not wait for pending
232
+ action reads. `QUERY_TIMEOUT` before the first snapshot means the observation
233
+ deadline expired without a known task state; it does not locate the cause in the
234
+ network or server. The overall deadline remains bounded. Usage changes are not
235
+ progress; continue quietly when there is no new narrative or actionable state.
236
+
237
+ ### One background observer
238
+
239
+ Use `enter-cli thread wait PROJECT_ID --task-id TASK_ID --stream --timeout 0`
240
+ with a host that supports managed background execution and incremental output delivery.
241
+ The CLI polls HTTP internally and emits batched progress until an action, terminal state,
242
+ interruption or explicit deadline. Ordinary progress does not end this process.
243
+ Consume events without blocking user interaction; do not restart it on report_progress.
244
+ The returned `stream_argv` / `stream_command` starts this observer.
245
+ `watch` remains a compatible streaming entry point.
246
+
247
+ Without `--stream`, wait retains its bounded single-JSON contract for buffered hosts.
248
+ This fallback can require repeated calls; CLI cannot enable host background scheduling.
249
+ Do not detach with shell `&` or assume buffered Bash output is delivered incrementally.
@@ -91,7 +91,7 @@ projectCmd
91
91
  .option("--prompt <text>", "Initial prompt for the project")
92
92
  .option("--model <id>", "Pin AI model (see `enter-cli models list`). Invalid IDs silently fall back to 'auto'.")
93
93
  .option("--plan-mode", "Start the project in Plan Mode (first turn produces a plan instead of code)")
94
- .option("--wait", "Wait until the first build completes")
94
+ .option("--wait", "Observe the first build; return on progress, input, completion or timeout")
95
95
  .option("--timeout <seconds>", "Timeout for --wait in seconds", "300")
96
96
  .action(async (id, opts, cmd) => {
97
97
  const seconds = Number(opts.timeout);
@@ -5,8 +5,11 @@ type InteractionOptions = {
5
5
  chatId?: string;
6
6
  turn?: number;
7
7
  requireBuild?: boolean;
8
+ timeout?: string;
9
+ afterProgress?: string;
10
+ progressInterval?: string;
8
11
  };
9
- export declare function threadInteraction(id: string, { taskId, chatId, turn, requireBuild }?: InteractionOptions): {
12
+ export declare function threadInteraction(id: string, { taskId, chatId, turn, requireBuild, timeout, afterProgress, progressInterval }?: InteractionOptions): {
10
13
  workflow: {
11
14
  actions?: {
12
15
  id: unknown;
@@ -26,6 +29,7 @@ export declare function threadInteraction(id: string, { taskId, chatId, turn, re
26
29
  interrupted: boolean;
27
30
  };
28
31
  reason?: string | undefined;
32
+ after_reporting?: string | undefined;
29
33
  state: string;
30
34
  task: {
31
35
  id: {} | null;
@@ -36,6 +40,9 @@ export declare function threadInteraction(id: string, { taskId, chatId, turn, re
36
40
  };
37
41
  monitoring_required: boolean;
38
42
  wait_command: string;
43
+ wait_argv: string[];
44
+ stream_command: string;
45
+ stream_argv: string[];
39
46
  watch_command: string;
40
47
  status_command: string;
41
48
  follow_up_command: string;
@@ -47,6 +54,8 @@ type MonitorOptions = {
47
54
  chatId?: string;
48
55
  cursor?: string;
49
56
  timeout: string;
57
+ afterProgress?: string;
58
+ progressInterval?: string;
50
59
  transport?: string;
51
60
  full?: boolean;
52
61
  compact?: boolean;
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { Command } from "commander";
2
3
  import { setTimeout as delay } from "node:timers/promises";
3
4
  import { writeFileSync, readFileSync } from "fs";
@@ -319,13 +320,7 @@ threadCmd
319
320
  params.end_turn = endTurn;
320
321
  const data = await client.get(`/v1/projects/${id}/thread/messages`, params);
321
322
  if (opts.text) {
322
- const rows = Array.isArray(data) ? data : data.messages ?? [];
323
- const messages = rows.flatMap((row) => {
324
- const content = row?.detail?.assistant_content?.content;
325
- return row?.message_type === "assistant_content" && typeof content === "string"
326
- ? [{ turn: row.turn, text: content }] : [];
327
- });
328
- print(getFormat(cmd), safeOutput({ messages }));
323
+ print(getFormat(cmd), safeOutput({ messages: assistantMessages(data) }));
329
324
  }
330
325
  else
331
326
  print(getFormat(cmd), data);
@@ -397,17 +392,64 @@ threadCmd
397
392
  ]);
398
393
  printTable(["Turn", "Name", "Status", "Model", "Credits", "Created"], rows);
399
394
  });
400
- export function threadInteraction(id, { taskId, chatId, turn, requireBuild } = {}) {
401
- const target = (taskId ? ` --task-id ${taskId}` : turn === undefined ? "" : ` --turn ${turn}`)
402
- + (chatId ? ` --chat-id ${chatId}` : "") + (requireBuild ? " --require-build" : "");
395
+ function assistantMessages(data) {
396
+ const rows = Array.isArray(data) ? data : data?.messages ?? [];
397
+ return rows.flatMap((row) => {
398
+ const content = row?.detail?.assistant_content?.content;
399
+ return row?.message_type === "assistant_content" && typeof content === "string" && content.trim()
400
+ ? [{ turn: row.turn, text: content }] : [];
401
+ });
402
+ }
403
+ async function readProgress(id, turn, signal, timeoutMs = 30000) {
404
+ const controller = new AbortController();
405
+ const abort = () => controller.abort(signal.reason);
406
+ signal.addEventListener("abort", abort, { once: true });
407
+ if (signal.aborted)
408
+ abort();
409
+ // Optional narrative must not hold up action handling or status indefinitely.
410
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
411
+ try {
412
+ const data = await client.get(`/v1/projects/${id}/thread/messages`, {
413
+ start_turn: String(turn.turn), end_turn: String(turn.turn),
414
+ ...(typeof turn.chat_id === "string" ? { chat_id: turn.chat_id } : {}),
415
+ }, controller.signal);
416
+ const messages = assistantMessages(data).filter(m => m.turn === undefined || String(m.turn) === String(turn.turn)).slice(-1);
417
+ const revision = createHash("sha256").update(JSON.stringify([id, turn.id, turn.chat_id, turn.turn, messages])).digest("hex");
418
+ return { source: "assistant_messages", available: true, revision,
419
+ messages: messages.map(m => ({ ...m, text: m.text.slice(0, 1600), ...(m.text.length > 1600 ? { truncated: true } : {}) })) };
420
+ }
421
+ catch (error) {
422
+ return { source: "assistant_messages", available: false, messages: [],
423
+ error: controller.signal.aborted ? { code: "PROGRESS_QUERY_TIMEOUT", retryable: true } : errorEnvelope(error).error };
424
+ }
425
+ finally {
426
+ clearTimeout(timer);
427
+ signal.removeEventListener("abort", abort);
428
+ }
429
+ }
430
+ export function threadInteraction(id, { taskId, chatId, turn, requireBuild, timeout, afterProgress, progressInterval } = {}) {
431
+ const targetArgs = [
432
+ ...(taskId ? ["--task-id", taskId] : turn === undefined ? [] : ["--turn", String(turn)]),
433
+ ...(chatId ? ["--chat-id", chatId] : []), ...(requireBuild ? ["--require-build"] : []),
434
+ ];
435
+ const target = targetArgs.length ? ` ${targetArgs.join(" ")}` : "";
436
+ const waitArgv = ["enter-cli", "--output", "json", "thread", "wait", id, ...targetArgs,
437
+ ...(afterProgress ? ["--after-progress", afterProgress] : []),
438
+ ...(progressInterval !== undefined ? ["--progress-interval", progressInterval] : []),
439
+ "--timeout", timeout === "0" ? "10" : timeout ?? "10"];
440
+ const streamArgv = [...waitArgv.slice(0, -2), "--stream", "--timeout", "0"];
441
+ const shellCommand = (argv) => argv.map(arg => /^[a-zA-Z0-9_./:-]+$/.test(arg) ? arg : "'" + arg.replace(/'/g, "'\"'\"'") + "'").join(" ");
403
442
  return {
404
443
  workflow: workflowResult({ status: "running", task_id: taskId, turn: { turn, chat_id: chatId } }),
405
444
  monitoring_required: true,
406
- wait_command: `enter-cli --output json thread wait ${id}${target} --timeout 10`,
407
- watch_command: `enter-cli thread watch ${id}${target} --timeout 60`,
445
+ wait_command: shellCommand(waitArgv),
446
+ wait_argv: waitArgv,
447
+ stream_command: shellCommand(streamArgv),
448
+ stream_argv: streamArgv,
449
+ watch_command: `enter-cli thread watch ${id}${target}${afterProgress ? ` --after-progress ${afterProgress}` : ""}${progressInterval !== undefined ? ` --progress-interval ${progressInterval}` : ""} --timeout ${timeout ?? "60"}`,
408
450
  status_command: `enter-cli --output json thread status ${id}${target} --timeout 10`,
409
451
  follow_up_command: `enter-cli --output json thread chat ${id}${chatId ? ` --chat-id ${chatId}` : ""} --file <message-file>`,
410
- instructions: "Accepted or timeout is not completion. Follow this task until blocked, failed or completed. Forward questions unchanged; approve only within existing authorization. Use short foreground waits and handle new user messages before continuing observation. Use background watch only with non-blocking output collection and completion notifications; do not follow it with a long blocking job wait. Observation timeout is normal: keep the same task and bounded wait, rather than increasing timeouts. CLI observation timeout and host job-wait timeout are separate limits, not elapsed time. Watching never approves or cancels remote work.",
452
+ instructions: "Follow workflow.next_action; tool completion or CLI exit 0 is not Enter task completion. First handle incoming user messages: reply from the last observation before refreshing status. Briefly relay batched progress.messages as Enter-reported progress; do not infer progress from credits, timestamps, or tool activity. With no new narrative, observe quietly; on user inquiry report the last known state and any unavailable progress. Suppress repeated revisions and heartbeats. After a bounded observer exits while observation is still required, continue with wait_argv or validated wait_command, including after observation timeout. Do not end the task or ask whether to continue merely because wait returned. Shell-quote each wait_argv argument and invoke bare enter-cli without sleep, cd, or other prefixes. Stop observing on completed, failed, blocked, resolve_error, or resume_when_requested and handle that state. Forward questions unchanged; approve only within existing authorization. Prefer one background stream_argv invocation when the host delivers incremental output and can handle user messages concurrently. While that process is active, consume its events without starting another observer; report_progress means relay text, not restart the process. Use bounded wait only as a fallback for buffered output or completion-only notifications; batched progress ends that observation. Keep the returned timeout and progress interval; do not lengthen waits. Streaming wait and watch require incremental output delivery; consume output nonblockingly. Do not resubmit work. Hosts own scheduling; observation never cancels or approves remote work.",
411
453
  };
412
454
  }
413
455
  function positiveNumber(value, option, integer = false) {
@@ -418,7 +460,10 @@ function positiveNumber(value, option, integer = false) {
418
460
  return number;
419
461
  }
420
462
  async function readThreadSnapshot(id, turnNumber, signal, taskId, chatId) {
421
- const data = await client.get(`/v1/projects/${id}/thread/turns`, chatId ? { chat_id: chatId } : undefined, signal);
463
+ // Both are read-only; serial reads unnecessarily consume the observation budget.
464
+ const turnsPromise = client.get(`/v1/projects/${id}/thread/turns`, chatId ? { chat_id: chatId } : undefined, signal);
465
+ const actionsPromise = fetchPendingActions(id, [], signal).then(actions => ({ actions }), error => ({ error }));
466
+ const data = await turnsPromise;
422
467
  const turns = data.turns ?? [];
423
468
  const selected = taskId ? turns.find(t => t.id === taskId || t.task_id === taskId)
424
469
  : turnNumber === undefined ? turns[0] : turns.find(t => Number(t.turn) === turnNumber);
@@ -442,7 +487,10 @@ async function readThreadSnapshot(id, turnNumber, signal, taskId, chatId) {
442
487
  if (["cancelled", "error", "failed"].includes(status)) {
443
488
  return { ...base, status: "failed", reason: `turn_${status}` };
444
489
  }
445
- const pending = (await fetchPendingActions(id, [], signal)).filter(a => String(a.turn) === String(selected.turn));
490
+ const actionResult = await actionsPromise;
491
+ if ("error" in actionResult)
492
+ throw actionResult.error;
493
+ const pending = actionResult.actions.filter(a => String(a.turn) === String(selected.turn));
446
494
  if (pending.length) {
447
495
  const context = { signal };
448
496
  const actions = await Promise.all(pending.map(async (a) => {
@@ -476,8 +524,11 @@ async function readThreadSnapshot(id, turnNumber, signal, taskId, chatId) {
476
524
  function projectSnapshot(snapshot, compact = false) {
477
525
  const workflow = workflowResult(snapshot);
478
526
  const result = { ...snapshot };
479
- if (compact && snapshot.turn)
527
+ if (compact && snapshot.turn) {
480
528
  result.turn = pick(snapshot.turn, ["id", "turn", "status", "turn_name", "created_at", "updated_at", "commit_id", "chat_id"]);
529
+ if (typeof snapshot.turn.turn_name === "string")
530
+ result.turn.turn_name = snapshot.turn.turn_name.slice(0, 1000);
531
+ }
481
532
  if (snapshot.status === "completed" && snapshot.turn) {
482
533
  result.messages_command = `enter-cli --output json thread messages ${snapshot.project_id} --turn ${snapshot.turn.turn} --text`;
483
534
  }
@@ -490,10 +541,13 @@ function projectSnapshot(snapshot, compact = false) {
490
541
  result.integrations = { cloud: supabase?.status ?? "unknown", ai: p.ai_connection_state ?? (p.ai_capability_enabled === true ? "enabled" : "unknown") };
491
542
  }
492
543
  result.workflow = workflow;
493
- return result;
544
+ // Buffered hosts may truncate verbose metadata: keep the decision and continuation first.
545
+ return { status: snapshot.status, workflow,
546
+ ...(result.wait_command ? { wait_command: result.wait_command, wait_argv: result.wait_argv, instructions: result.instructions } : {}),
547
+ ...result };
494
548
  }
495
549
  // Owns observation and lifetime only; callers choose JSON or NDJSON rendering.
496
- async function observeThread(id, opts, wait, onSnapshot) {
550
+ async function observeThread(id, opts, wait, onSnapshot, continuous = false) {
497
551
  let target = opts.turn === undefined ? undefined : positiveNumber(opts.turn, "--turn", true);
498
552
  if (opts.taskId && target !== undefined)
499
553
  throw new Error("Use only one of --task-id and --turn");
@@ -501,25 +555,35 @@ async function observeThread(id, opts, wait, onSnapshot) {
501
555
  throw new Error("--transport must be auto or poll");
502
556
  if (opts.cursor && !/^\d+-\d+$/.test(opts.cursor))
503
557
  throw new Error("--cursor must be an event ID such as 123-0");
504
- const timeoutMs = positiveNumber(opts.timeout, "--timeout") * 1000;
558
+ if (opts.afterProgress && !/^[a-f0-9]{64}$/.test(opts.afterProgress))
559
+ throw new Error("--after-progress must be a returned progress revision");
560
+ const progressIntervalMs = Number(opts.progressInterval ?? "10") * 1000;
561
+ if (!Number.isFinite(progressIntervalMs) || progressIntervalMs < 0 || progressIntervalMs > 2147483647)
562
+ throw new Error("--progress-interval must be between 0 and 2147483 seconds");
563
+ const progressStartedAt = Date.now();
564
+ const timeoutMs = continuous && opts.timeout === "0" ? 0 : positiveNumber(opts.timeout, "--timeout") * 1000;
505
565
  if (timeoutMs > 2147483647)
506
566
  throw new Error("--timeout is too large");
507
567
  const controller = new AbortController();
508
- const timer = setTimeout(() => controller.abort(), timeoutMs);
568
+ const timer = timeoutMs === 0 ? undefined : setTimeout(() => controller.abort(), timeoutMs);
509
569
  const interrupt = () => controller.abort("SIGINT");
510
570
  const terminate = () => controller.abort("SIGTERM");
511
571
  process.once("SIGINT", interrupt);
512
572
  process.once("SIGTERM", terminate);
513
573
  let last = { status: "unknown", project_id: id, turn: null };
514
574
  let querying = false;
575
+ let hasSnapshot = false;
576
+ let progressPromise;
577
+ let progressRead;
515
578
  let stream;
516
579
  let reconcileUntil = 0;
517
580
  const emit = (extra = {}, final = false) => onSnapshot({
518
581
  ...last,
519
582
  ...(["idle", "running", "queued", "pending", "unknown"].includes(last.status)
520
- ? threadInteraction(id, { taskId: opts.taskId, chatId: opts.chatId, turn: target, requireBuild: opts.requireBuild }) : {}),
583
+ ? threadInteraction(id, { taskId: opts.taskId, chatId: opts.chatId, turn: target, requireBuild: opts.requireBuild,
584
+ timeout: wait ? opts.timeout : undefined, progressInterval: opts.progressInterval === "10" ? undefined : opts.progressInterval, afterProgress: last.progress?.messages.length ? last.progress.revision : opts.afterProgress }) : {}),
521
585
  ...(opts.taskId ? { task_id: opts.taskId } : {}),
522
- ...(stream ? { transport: stream.mode, transport_reason: stream.reason, cursor: stream.cursor } : {}), ...extra,
586
+ ...(stream ? { transport: stream.mode, transport_reason: stream.reason, cursor: stream.cursor } : wait ? { transport: "polling" } : {}), ...extra,
523
587
  }, final);
524
588
  const interruption = () => {
525
589
  const reason = controller.signal.reason;
@@ -534,19 +598,32 @@ async function observeThread(id, opts, wait, onSnapshot) {
534
598
  throw stream.failure;
535
599
  const revision = stream?.revision ?? 0;
536
600
  querying = true;
601
+ // A persistent observer still bounds each read so a stalled endpoint cannot hang forever.
602
+ const queryController = new AbortController();
603
+ const abortQuery = () => queryController.abort(controller.signal.reason);
604
+ controller.signal.addEventListener("abort", abortQuery, { once: true });
605
+ const queryTimer = setTimeout(() => queryController.abort(), 30000);
537
606
  try {
538
- last = await readThreadSnapshot(id, target, controller.signal, opts.taskId, opts.chatId);
607
+ last = await readThreadSnapshot(id, target, queryController.signal, opts.taskId, opts.chatId);
608
+ last.observed_at = new Date().toISOString();
609
+ hasSnapshot = true;
539
610
  }
540
611
  catch (error) {
541
- // A read outage does not end remote work. Keep bounded observation alive;
542
- // never retry writes or authentication/permission failures here.
612
+ if (queryController.signal.aborted && !controller.signal.aborted) {
613
+ error = new RequestError("QUERY_TIMEOUT", "Enter status query exceeded its deadline.", true);
614
+ }
543
615
  if (!wait || controller.signal.aborted || !(error instanceof RequestError) || !error.retryable)
544
616
  throw error;
545
617
  querying = false;
546
- emit({ ...errorEnvelope(error), observation_retrying: true, last_observed: true });
618
+ emit({ ...errorEnvelope(error), observation_retrying: true, last_observed: hasSnapshot });
547
619
  await delay(1000, undefined, { signal: controller.signal });
548
620
  continue;
549
621
  }
622
+ finally {
623
+ queryController.abort();
624
+ clearTimeout(queryTimer);
625
+ controller.signal.removeEventListener("abort", abortQuery);
626
+ }
550
627
  if (opts.requireBuild && last.status === "completed") {
551
628
  const build = last.project?.build_status;
552
629
  const matches = Boolean(last.turn?.commit_id && build?.commit_id === last.turn.commit_id);
@@ -556,6 +633,30 @@ async function observeThread(id, opts, wait, onSnapshot) {
556
633
  last = { ...last, status: "failed", reason: "build_failed" };
557
634
  }
558
635
  querying = false;
636
+ if (last.turn && ["running", "completed"].includes(last.status)) {
637
+ if (!wait || last.status === "completed") {
638
+ last.progress = await readProgress(id, last.turn, controller.signal, 3000);
639
+ }
640
+ else {
641
+ const key = JSON.stringify([last.turn.id, last.turn.chat_id, last.turn.turn]);
642
+ if (progressRead?.key !== key)
643
+ progressRead = { key, pending: false, nextReadAt: 0 };
644
+ const read = progressRead;
645
+ if (!read.pending && Date.now() >= read.nextReadAt) {
646
+ read.pending = true;
647
+ read.nextReadAt = Date.now() + 2000;
648
+ // Narrative latency must not delay authoritative state/card checks.
649
+ progressPromise = readProgress(id, last.turn, controller.signal).then(value => {
650
+ read.value = value;
651
+ read.pending = false;
652
+ });
653
+ }
654
+ last.progress = read.value ?? { source: "assistant_messages", available: false, messages: [] };
655
+ }
656
+ }
657
+ const stopped = interruption();
658
+ if (stopped !== undefined)
659
+ return stopped;
559
660
  if (target === undefined && last.turn && !opts.taskId)
560
661
  target = positiveNumber(String(last.turn.turn), "server turn", true);
561
662
  // A newly accepted task can be temporarily absent from both reads. Keep
@@ -565,7 +666,9 @@ async function observeThread(id, opts, wait, onSnapshot) {
565
666
  emit({}, true);
566
667
  return last.status === "failed" || last.status === "unknown" ? 1 : 0;
567
668
  }
568
- if (!stream && opts.transport !== "poll") {
669
+ if (controller.signal.aborted)
670
+ break;
671
+ if (!stream && opts.transport === "auto") {
569
672
  stream = new ThreadEvents(id, {
570
673
  signal: controller.signal, turn: target ?? (last.turn ? Number(last.turn.turn) : undefined),
571
674
  chatId: opts.chatId ?? last.turn?.chat_id, cursor: opts.cursor, stateOnly: true,
@@ -573,16 +676,24 @@ async function observeThread(id, opts, wait, onSnapshot) {
573
676
  reconcileUntil = Date.now() + 1500; },
574
677
  });
575
678
  }
679
+ const progressChanged = last.progress?.available && last.progress.messages.length
680
+ && last.progress.revision !== opts.afterProgress;
681
+ if (!continuous && progressChanged && Date.now() - progressStartedAt >= progressIntervalMs) {
682
+ emit({ progress_changed: true }, true);
683
+ return 0;
684
+ }
576
685
  emit();
577
686
  if (stream)
578
687
  await stream.wait(revision, Date.now() < reconcileUntil ? 250 : stream.mode === "websocket" ? 30000 : 2000);
579
- else
580
- await delay(2000, undefined, { signal: controller.signal });
688
+ else {
689
+ const pause = delay(2000, undefined, { signal: controller.signal });
690
+ await (progressRead?.pending && progressPromise ? Promise.race([pause, progressPromise]) : pause);
691
+ }
581
692
  }
582
693
  const interrupted = interruption();
583
694
  if (interrupted !== undefined)
584
695
  return interrupted;
585
- emit({ wait_timed_out: true, query_timed_out: false }, true);
696
+ emit({ wait_timed_out: true, query_timed_out: false, last_observed: hasSnapshot }, true);
586
697
  return 2;
587
698
  }
588
699
  catch (error) {
@@ -590,13 +701,20 @@ async function observeThread(id, opts, wait, onSnapshot) {
590
701
  if (interrupted !== undefined)
591
702
  return interrupted;
592
703
  if (!controller.signal.aborted) {
593
- emit({ ...errorEnvelope(error), last_observed: true }, true);
704
+ emit({ ...errorEnvelope(error), last_observed: true,
705
+ ...(error instanceof RequestError && error.code === "QUERY_TIMEOUT" ? { query_timed_out: true } : {}),
706
+ }, true);
594
707
  return 1;
595
708
  }
596
- emit({ wait_timed_out: wait, query_timed_out: querying, ...(querying ? { error: { code: "QUERY_TIMEOUT", retryable: true, outcome_unknown: false } } : {}) }, true);
709
+ if (wait && hasSnapshot) {
710
+ emit({ wait_timed_out: true, query_timed_out: false, last_observed: true }, true);
711
+ return 2;
712
+ }
713
+ emit({ wait_timed_out: wait, query_timed_out: querying, ...(querying ? { error: { code: "QUERY_TIMEOUT", message: "The observation deadline expired before a status snapshot was available. Task state is unknown; this does not identify a network or server failure.", retryable: true, outcome_unknown: false } } : {}) }, true);
597
714
  return wait && !querying ? 2 : 1;
598
715
  }
599
716
  finally {
717
+ controller.abort();
600
718
  stream?.close();
601
719
  clearTimeout(timer);
602
720
  process.removeListener("SIGINT", interrupt);
@@ -606,38 +724,68 @@ async function observeThread(id, opts, wait, onSnapshot) {
606
724
  export async function reportThread(id, opts, cmd, wait, watch = false) {
607
725
  if (opts.full && opts.compact)
608
726
  throw new Error("Use only one of --full and --compact");
727
+ const progressIntervalMs = Number(opts.progressInterval ?? "10") * 1000;
728
+ const heartbeatIntervalMs = Math.max(30000, progressIntervalMs);
609
729
  let signature = "";
730
+ let lastOutputAt = 0;
731
+ let lastState = "";
732
+ let reportedProgress = opts.afterProgress;
610
733
  process.exitCode = await observeThread(id, opts, wait, (snapshot, final) => {
611
- const data = projectSnapshot(snapshot, !opts.full && (opts.compact || watch));
734
+ if (!watch && !final)
735
+ return;
736
+ const now = Date.now();
737
+ const state = JSON.stringify([snapshot.status, snapshot.reason, snapshot.turn?.id, snapshot.error]);
738
+ const stateChanged = state !== lastState;
739
+ const intervalElapsed = now - lastOutputAt >= progressIntervalMs;
740
+ const heartbeatDue = now - lastOutputAt >= heartbeatIntervalMs;
741
+ // Skip output preparation inside the batch window without delaying state transitions.
742
+ if (watch && !final && !stateChanged && !intervalElapsed && !heartbeatDue)
743
+ return;
744
+ const progressChanged = Boolean(snapshot.progress?.available && snapshot.progress.messages.length
745
+ && snapshot.progress.revision !== reportedProgress);
746
+ const data = projectSnapshot({ ...snapshot, progress_changed: progressChanged }, !opts.full);
612
747
  if (!watch) {
613
- if (final)
614
- print(getFormat(cmd), safeOutput(data));
748
+ print(getFormat(cmd), safeOutput(data));
615
749
  return;
616
750
  }
617
751
  // Cursor-only changes should not flood agents with identical snapshots.
618
- const next = JSON.stringify({ ...data, cursor: undefined });
619
- if (next !== signature || final) {
620
- process.stdout.write(JSON.stringify(safeOutput({ type: final ? "result" : "snapshot", ...data })) + "\n");
752
+ const next = JSON.stringify({ ...data, cursor: undefined, observed_at: undefined, progress_changed: undefined,
753
+ workflow: workflowResult({ ...snapshot, progress_changed: false }) });
754
+ const changed = next !== signature;
755
+ if (final || stateChanged || (changed && intervalElapsed) || heartbeatDue) {
756
+ lastState = state;
757
+ process.stdout.write(JSON.stringify(safeOutput({ type: final ? "result" : changed || snapshot.error ? "snapshot" : "heartbeat", ...data, progress_changed: progressChanged, emitted_at: new Date(now).toISOString() })) + "\n");
621
758
  signature = next;
759
+ lastOutputAt = now;
760
+ if (snapshot.progress?.available && snapshot.progress.messages.length)
761
+ reportedProgress = snapshot.progress.revision;
622
762
  }
623
- });
763
+ }, watch);
624
764
  }
625
765
  for (const [name, timeout, description] of [
626
766
  ["status", "30", "Read one task snapshot; never wait for completion"],
627
- ["wait", "10", "Wait for an action or terminal state using events with polling fallback"],
767
+ ["wait", "10", "Wait for batched progress, an action or terminal state using HTTP polling"],
628
768
  ["watch", "60", "Stream meaningful state changes as NDJSON; exit on pending actions or terminal state"],
629
769
  ]) {
630
- threadCmd.command(`${name} <project_id>`).description(description)
770
+ const command = threadCmd.command(`${name} <project_id>`).description(description)
631
771
  .option("--turn <n>", "Select a fixed turn number")
632
772
  .option("--task-id <id>", "Follow exactly the task returned by chat/approve, including queue time")
633
773
  .option("--chat-id <id>", "Scope turn lookup and events to a chat")
634
774
  .option("--cursor <id>", "Resume stream after an event ID")
635
- .option("--transport <mode>", "auto: WebSocket with fallback; poll: HTTP only", "auto")
775
+ .option("--after-progress <revision>", "Wait for narrative progress after a returned revision; preserves task identity")
776
+ .option("--progress-interval <seconds>", "Batch ordinary progress; 0 reports every change; actions/terminal states bypass this interval", "10")
777
+ .option("--transport <mode>", "poll: HTTP only (default); auto: opt in to WebSocket with fallback", "poll")
636
778
  .option("--require-build", "Require a matching successful build before reporting completion")
637
- .option("--compact", "Return only monitoring fields (default for watch)")
638
- .option("--full", "Include full sanitized metadata (default for status/wait)")
639
- .option("--timeout <seconds>", "Bound the entire call without cancelling Enter", timeout)
640
- .action(async (id, opts, cmd) => reportThread(id, opts, cmd, name !== "status", name === "watch"));
779
+ .option("--compact", "Return only monitoring fields (default)")
780
+ .option("--full", "Include full sanitized metadata, including usage")
781
+ .option("--timeout <seconds>", name === "watch" ? "Observation lifetime; 0 waits until action/terminal state or interruption" : "Bound the entire call without cancelling Enter", timeout)
782
+ .action(async (id, opts, cmd) => {
783
+ if (opts.stream && cmd.getOptionValueSource("timeout") === "default")
784
+ opts.timeout = "0";
785
+ await reportThread(id, opts, cmd, name !== "status", name === "watch" || Boolean(opts.stream));
786
+ });
787
+ if (name === "wait")
788
+ command.option("--stream", "Keep one observer running and emit batched NDJSON; default timeout 0, host owns background execution");
641
789
  }
642
790
  threadCmd
643
791
  .command("diff <project_id> <turn_number>")
@@ -110,7 +110,10 @@ export class ThreadEvents {
110
110
  if ([401, 403].includes(status)) {
111
111
  retryable = false;
112
112
  reason = status === 401 ? "authentication_required" : "permission_denied";
113
- this.failure = new RequestError("STREAM_AUTH_ERROR", "Enter event stream rejected access. Check authentication and project permissions.");
113
+ // State observers retain authoritative HTTP polling; raw message followers cannot.
114
+ if (!this.options.stateOnly) {
115
+ this.failure = new RequestError("STREAM_AUTH_ERROR", "Enter event stream rejected access. Check authentication and project permissions.");
116
+ }
114
117
  }
115
118
  else if ([404, 405, 426, 501].includes(status)) {
116
119
  retryable = false;
@@ -10,6 +10,7 @@ export interface WorkflowSnapshot {
10
10
  interrupted?: boolean;
11
11
  wait_timed_out?: boolean;
12
12
  observation_retrying?: boolean;
13
+ progress_changed?: boolean;
13
14
  error?: {
14
15
  retryable?: boolean;
15
16
  };
@@ -34,6 +35,7 @@ export declare function workflowResult(snapshot: WorkflowSnapshot): {
34
35
  interrupted: boolean;
35
36
  };
36
37
  reason?: string | undefined;
38
+ after_reporting?: string | undefined;
37
39
  state: string;
38
40
  task: {
39
41
  id: {} | null;
package/dist/workflow.js CHANGED
@@ -10,12 +10,14 @@ export function workflowResult(snapshot) {
10
10
  : state === 'needs_input' ? 'handle_actions'
11
11
  : state === 'completed' ? 'read_delivery'
12
12
  : state === 'failed' ? 'report_failure'
13
- : state === 'idle' ? 'none'
14
- : 'observe';
13
+ : snapshot.progress_changed ? 'report_progress'
14
+ : state === 'idle' ? 'none'
15
+ : 'observe';
15
16
  return {
16
17
  state,
17
18
  task: { id: snapshot.task_id ?? turn?.task_id ?? turn?.id ?? null, turn: turn?.turn ?? null, chat_id: turn?.chat_id ?? null },
18
19
  next_action: next,
20
+ ...(next === "report_progress" ? { after_reporting: "observe" } : {}),
19
21
  ...(snapshot.reason ? { reason: snapshot.reason } : {}),
20
22
  observation: { timed_out: Boolean(snapshot.wait_timed_out), retrying: Boolean(snapshot.observation_retrying), interrupted: Boolean(snapshot.interrupted) },
21
23
  ...(snapshot.project ? { build: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enter-pro/enter-cli",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "Enter CLI - manage Enter platform resources from the command line",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -14,17 +14,21 @@ description: 使用 Enter CLI 创建和修改网站或应用,跟踪任务、
14
14
  ## 流程
15
15
 
16
16
  1. 从用户上下文确定项目;新建时确定 workspace。通过 `project create` 或 `thread chat` 提交一次,保存 task_id。accepted 或观察超时不代表完成,也不是重新提交的理由。
17
- 2. 按下方观察规则执行返回的 wait_command,按同一 task_id 继续;需交付构建时加 `--require-build`。新增需求单独记录返回的 task_id。
17
+ 2. 按下方宿主能力规则选择返回的 stream_command 或 wait_command,按同一 task_id 继续;需交付构建时加 `--require-build`。新增需求单独记录返回的 task_id。
18
18
  3. 状态的 `workflow` 给出任务关联、next_action 和构建匹配信息。遇到 `handle_actions` 按下方卡片规则处理;`read_delivery` 执行 messages_command,取得 Enter 的总结与验证证据。未知关联不代表提交失败,不猜测它属于其他已完成任务。
19
19
  4. 依用户已有授权继续,不因阶段切换反复确认。明确要求用户决策的计划和提问仍需转交。写操作超时或解析失败先查真实状态,再决定是否重试。
20
20
  5. 交付说明完成内容、预览/发布地址、Enter 验证结果及待配置项;代码存在、配置保存、构建成功、真实服务可用分开判断。发布按用户授权范围执行。
21
21
 
22
22
  ## 观察与响应
23
23
 
24
- - 默认执行短时 wait(返回命令为 10 秒),返回后先处理用户新消息,再按同一任务续接。观察超时只结束本次观察;不要为等到完成不断增加超时,也不要重新提交任务。
25
- - 只有宿主支持完成通知和非阻塞取结果时,才选择后台 watch。启动后让出对话执行权,由通知续接;不要再用数分钟的阻塞工具等待后台任务。若无法可靠接收通知,使用短时 wait。始终只保留一个观察路径,不叠加 sleep 或反复查询。
26
- - 用户问进度时,先用最新已知状态答复并标明是否为上次观察;需要刷新再执行一次 status,随后继续原任务。不要等到构建完成才回答。收到完成通知先读取该任务结果,再决定是否需要继续观察。
27
- - CLI `--timeout` 限制观察进程寿命,宿主的取结果等待参数限制一次工具调用;两者都不是实际耗时。耗时看时间戳,任务是否结束看返回状态,不能由等待上限推断卡死。
24
+ - CLI HTTP 获取真实状态与 Enter 最新进度原文。宿主作为传话者:`workflow.next_action=report_progress` 时先转述 `progress.messages`,再按 `after_reporting=observe` 续接;处理其他 `workflow.next_action`,执行返回的命令;不翻原始日志自行分析开发阶段,不把 Enter 的自述当独立验收。`progress.available=false` 表示暂时无法获取进度,不能说没有进展;有数据但 revision 未变才是没有新内容。不编造百分比,不用额度消耗、时间戳变化证明进展。默认只读取监控字段,除非用户查询详情,不使用 --full。
25
+ - 首选单个 `stream_command`(`thread wait --stream --timeout 0`):仅在宿主支持后台执行、增量输出通知和同时处理用户消息时使用。CLI 内部轮询,宿主非阻塞消费阶段性事件;运行期间不要另起 wait,不因 report_progress 重启观察。收到最终 result 后再按状态处理;不使用 shell & 脱离宿主管理。watch 保留为同一流式观察能力的兼容入口。
26
+ - 同步或仅有完成通知的宿主回退到 `wait_command`:它在普通进度合并窗口到达且有新进展时退出,所以只支持任务完成通知的后台宿主,也能被新进展唤醒。支持后台任务时在后台运行并用完成通知续接,不阻塞等整个网站完成;同步宿主直接执行有界 wait。
27
+ - `wait` 返回 `progress_changed=true` 时直接简短转述,再执行带 `--after-progress` 的续接命令。只有确认宿主能在新 stdout 到达时唤醒 Agent,才选择 `watch_command`;仅能手动读取运行中输出还不够。不得用反复 job_output 空读替代通知,也不得阻塞读取直到后台任务结束。watch 无限观察仅显式 `--timeout 0`。
28
+ - 按 CLI 合并后的进展简短汇报;细碎技术变化无需逐条复述。面向用户不解释 CLI 协议、job 状态或调度过程。相同 revision、heartbeat 和无变化超时无需重复刷屏。超时结束的是本次观察,不是远端工作。保持同一 task_id、turn、chat 和构建要求,不重提、不询问是否继续,不写 sleep 或 status 循环。
29
+ - 用户插话时立即用最近已知的进度与 `observed_at` 回答,标明是上次观察;之后按需刷新一次 status,再继续原任务。不要为了拿最新状态先阻塞回答。新增需求才提交 chat,单纯问进度不触发构建。
30
+ - 普通进度默认每 10 秒合并一次(`--progress-interval`),保留最新原文,不猜测阶段;问题卡、完成和失败立即返回。保留返回的间隔与时限,不自行调长等待。
31
+ - `wait` 默认最多 10 秒,`watch` 默认 60 秒,显式时限会保留在续接命令中。这是观察上限,不是心跳周期或响应保证;不得为等到完成不断延长阻塞调用。宿主如果本身不支持插话或后台唤醒,CLI 不能保证即时响应或主动通知。
28
32
 
29
33
  ## 卡片
30
34
 
@@ -33,4 +37,4 @@ description: 使用 Enter CLI 创建和修改网站或应用,跟踪任务、
33
37
  - 普通确认:已有授权就 approve;需要新的决定时询问。用户拒绝才 reject。
34
38
  - 配置输入:已有授权和值/文件就提交;缺什么只问什么。普通配置可直接询问,真实密钥使用可用的安全输入或用户指定文件;表单是替代入口,不强制绕路。OAuth/secret/Stripe 的字段、stdin 和更新方法见 [配置输入](references/configuration.md),仅遇到这些卡片时读取。
35
39
 
36
- CLI 负责事件监听、断线恢复和查询兜底;宿主负责后台调度与用户交互。等待工具不自动批准卡片。宿主没有浏览器不影响 Enter 内部验证,不以关键词扫描替代其验证结果。
40
+ CLI 负责 HTTP 观察和可选的事件监听;宿主负责后台调度与用户交互。等待工具不自动批准卡片。宿主没有浏览器不影响 Enter 内部验证,不以关键词扫描替代其验证结果。