@enter-pro/enter-cli 0.4.2 → 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.
@@ -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";
@@ -7,7 +8,8 @@ import { pollUntil, TimeoutError } from "../poll.js";
7
8
  import { resolveLifecycleStatus } from "../lifecycle.js";
8
9
  import { ThreadEvents, isStateEvent } from "../thread-events.js";
9
10
  import { safeOutput } from "../safe-output.js";
10
- import { errorEnvelope } from "../errors.js";
11
+ import { RequestError, errorEnvelope } from "../errors.js";
12
+ import { workflowResult } from "../workflow.js";
11
13
  import { registerThreadTasks } from "./thread-tasks.js";
12
14
  export const threadCmd = new Command("thread").description("Manage project threads and chat");
13
15
  registerThreadTasks(threadCmd);
@@ -27,6 +29,8 @@ function normalizeAction(raw) {
27
29
  updated_at: String(raw.updated_at ?? raw.UpdatedAt ?? ""),
28
30
  };
29
31
  }
32
+ // Single registry: adding a new tool only requires editing one row.
33
+ const STRIPE_INPUT_INSTRUCTIONS = "Submit the Stripe key through --secret-value-stdin from an authorized input source. Reuse input already supplied for this task; otherwise offer an available secure input or local-file path. Do not echo the key or embed it in shell text or build prompts. Report connection errors without assuming their cause.";
30
34
  async function loadToolCallArgs(projectId, action, toolName, context = {}) {
31
35
  const turn = String(action.turn);
32
36
  const data = await (context.messages ??= client.get(`/v1/projects/${projectId}/thread/messages`, { start_turn: turn, end_turn: turn }, context.signal));
@@ -73,18 +77,22 @@ async function loadToolCallArgs(projectId, action, toolName, context = {}) {
73
77
  }
74
78
  return { kind: "not_found" };
75
79
  }
80
+ const ANSWERS_FORMAT = 'Use the exact original question text as each key, not a host question ID. Each value must be {"selected_options":["exact option label"],"other_text":"optional free text"}. Use an empty selected_options array for free-text-only answers. See thread approve --help.';
76
81
  const TOOL_HANDLERS = {
77
82
  // ask_user_question: surface the questions array directly so callers don't
78
83
  // have to fetch + parse thread messages to know what to ask the user.
79
84
  ask_user_question: {
80
85
  kind: "questions",
81
- approveSuffix: (pid, a) => `--answers '<json>' OR enter-cli thread approve ${pid} ${a.action_id} --skip-answers`,
82
- instructions: "Questions are included in this action's `questions` field. Forward each question (with its options and multiSelect) to the user, collect answers, then run approve_command --answers '<json>' (or --skip-answers).",
86
+ approveSuffix: "--answers '<json>'",
87
+ instructions: `Forward questions, options and multiSelect to the user unchanged. Map their response back to the original question text. ${ANSWERS_FORMAT} After receiving the user response, run approve_command with that JSON. Use skip_command only if the user asks to skip.`,
83
88
  enrich: async (projectId, action, context) => {
84
89
  const args = await loadToolCallArgs(projectId, action, "ask_user_question", context);
85
90
  switch (args.kind) {
86
91
  case "ok":
87
- return { questions: args.value.questions ?? [] };
92
+ return {
93
+ questions: args.value.questions ?? [],
94
+ skip_command: `enter-cli thread approve ${projectId} ${action.action_id} --skip-answers`,
95
+ };
88
96
  case "parse_error":
89
97
  return { questions: { raw_arguments: args.raw_arguments, parse_error: true } };
90
98
  case "not_found":
@@ -94,31 +102,31 @@ const TOOL_HANDLERS = {
94
102
  },
95
103
  supabase_add_secret: {
96
104
  kind: "secret",
97
- approveSuffix: () => `--secret-name <NAME> --secret-value-stdin`,
98
- instructions: "Use a secure input surface for the secret name and value; direct CLI callers can use stdin. Never request or echo credentials in ordinary chat.",
105
+ approveSuffix: `--secret-name <NAME> --secret-value-stdin`,
106
+ instructions: "Submit the secret name and value through --secret-value-stdin from an authorized input source. Reuse input already supplied for this task; otherwise ask for the missing value through an available secure input or local-file path. Do not echo the value or embed it in shell text or build prompts.",
99
107
  },
100
108
  supabase_configure_auth_provider: {
101
109
  kind: "auth_provider",
102
- approveSuffix: () => "",
103
- instructions: "Configure the requested provider through Enter's secure form, then approve. For direct CLI use, --auth-config-stdin accepts provider configuration JSON through stdin. Never collect credentials in ordinary chat. Approval verifies saved configuration with the backend.",
104
- enrich: async (projectId, action, context) => ({ provider: await authProviderFor(projectId, action, context) }),
110
+ approveSuffix: "",
111
+ instructions: "Configure this provider with --auth-config-stdin using an authorized input source, or use Enter's provider form. Reuse configuration already supplied for this task; ask only for missing fields. After form submission query status before approving again. Missing input is not build failure.",
112
+ enrich: async (projectId, action, context) => authProviderInput(await authProviderFor(projectId, action, context)),
105
113
  },
106
114
  stripe_enable: {
107
115
  kind: "secret",
108
- approveSuffix: () => `--secret-value-stdin`,
109
- instructions: "Use a secure input surface or direct CLI stdin for the Stripe key. Never request credentials in ordinary chat.",
116
+ approveSuffix: `--secret-value-stdin`,
117
+ instructions: STRIPE_INPUT_INSTRUCTIONS,
110
118
  },
111
119
  stripe_update_key_and_migrate: {
112
120
  kind: "secret",
113
- approveSuffix: () => `--secret-value-stdin`,
114
- instructions: "Product IDs come from the pending tool call. Supply the new Stripe key through a secure input surface or direct CLI stdin, never ordinary chat.",
121
+ approveSuffix: `--secret-value-stdin`,
122
+ instructions: `${STRIPE_INPUT_INSTRUCTIONS} Product IDs come from the pending tool call.`,
115
123
  },
116
124
  // confirm_plan_mode: surface the plan text directly on the action so callers
117
125
  // don't have to fetch + parse thread messages themselves.
118
126
  confirm_plan_mode: {
119
127
  kind: "none",
120
- approveSuffix: () => "",
121
- instructions: "Plan is included in this action's `plan` field. Show it to the user, then run approve_command.",
128
+ approveSuffix: "",
129
+ instructions: "Plan is included in this action's `plan` field. Present it for the user's approval and wait for their decision before running approve_command. Displaying the plan is not approval.",
122
130
  enrich: async (projectId, action, context) => {
123
131
  const args = await loadToolCallArgs(projectId, action, "confirm_plan_mode", context);
124
132
  switch (args.kind) {
@@ -141,7 +149,7 @@ const TOOL_HANDLERS = {
141
149
  };
142
150
  const DEFAULT_HANDLER = {
143
151
  kind: "none",
144
- approveSuffix: () => "",
152
+ approveSuffix: "",
145
153
  instructions: "No additional input required. Approve only within the user-authorized scope; input_kind none does not itself grant authorization.",
146
154
  };
147
155
  const FEATURE_ENABLE_ROUTES = new Map([
@@ -156,6 +164,25 @@ const AUTH_PROVIDER_FIELDS = {
156
164
  alipay: ["enabled", "app_id", "private_key"],
157
165
  feishu: ["enabled", "app_id", "app_secret"],
158
166
  };
167
+ // Input requirements are separate from optional provider settings. Never put
168
+ // stored configuration values (including masked secrets) in action metadata.
169
+ const AUTH_PROVIDER_INPUTS = {
170
+ google: ["client_ids", "client_secret"],
171
+ wechat: ["client_id", "client_secret"],
172
+ alipay: ["app_id", "private_key"],
173
+ feishu: ["app_id", "app_secret"],
174
+ };
175
+ function authProviderInput(provider) {
176
+ return {
177
+ provider,
178
+ configuration: {
179
+ input_surface: "enter_project_auth_form",
180
+ input_methods: ["auth_config_stdin", "enter_project_auth_form"],
181
+ fields: AUTH_PROVIDER_INPUTS[provider],
182
+ instructions: "Supply the listed fields as JSON through --auth-config-stdin from an authorized input source; Enter's provider form is an alternative, not a required detour. If input is missing, offer an available secure input or local-file path. Do not echo credentials or embed them in shell text or build prompts. Use the actual OAuth callback URL when configuring the provider. After saving through the form query status, since it may already have approved the action.",
183
+ },
184
+ };
185
+ }
159
186
  async function authProviderFor(projectId, action, context = {}) {
160
187
  if (!action.tool_call_id)
161
188
  throw new Error("Auth provider action is missing its tool_call_id");
@@ -183,7 +210,7 @@ function handlerFor(toolName) {
183
210
  }
184
211
  function buildApproveCommand(projectId, action) {
185
212
  const base = `enter-cli thread approve ${projectId} ${action.action_id}`;
186
- const suffix = handlerFor(action.tool_name).approveSuffix(projectId, action);
213
+ const suffix = handlerFor(action.tool_name).approveSuffix;
187
214
  return suffix ? `${base} ${suffix}` : base;
188
215
  }
189
216
  async function fetchPendingActions(projectId, actionIds = [], signal) {
@@ -217,8 +244,7 @@ threadCmd
217
244
  content = opts.message;
218
245
  }
219
246
  else {
220
- console.error("Error: either -m <text> or --file <path> is required");
221
- process.exit(1);
247
+ throw new Error("Provide non-empty input using --message, --file or --stdin");
222
248
  }
223
249
  if (!content.trim())
224
250
  throw new Error("Message must not be empty");
@@ -241,6 +267,7 @@ threadCmd
241
267
  .option("--end-turn <n>", "End turn number")
242
268
  .option("--turn <n>", "Get messages for a specific turn (shorthand for --start-turn N --end-turn N)")
243
269
  .option("--latest", "Get messages from the most recent turn (most common usage)")
270
+ .option("--text", "Return assistant text with turn numbers, without raw event envelopes")
244
271
  .option("--tail <n>", "Get messages from the last N turns")
245
272
  .option("--follow", "Stream NDJSON events over WebSocket; no automatic approval")
246
273
  .option("--cursor <id>", "Resume after a previously returned event ID")
@@ -249,6 +276,8 @@ threadCmd
249
276
  .option("--max-events <n>", "Stop --follow after N events")
250
277
  .action(async (id, opts, cmd) => {
251
278
  if (opts.follow) {
279
+ if (opts.text)
280
+ throw new Error("--text is not supported with --follow");
252
281
  await followThreadStream(id, opts);
253
282
  return;
254
283
  }
@@ -264,7 +293,7 @@ threadCmd
264
293
  const resp = turnsData;
265
294
  const turns = resp.turns || [];
266
295
  if (turns.length === 0) {
267
- printMessage("No turns found.");
296
+ print(getFormat(cmd), { messages: [] });
268
297
  return;
269
298
  }
270
299
  if (opts.latest) {
@@ -283,15 +312,18 @@ threadCmd
283
312
  }
284
313
  }
285
314
  if (!startTurn) {
286
- console.error("Error: specify --start-turn, --turn, --latest, --tail, or --follow");
287
- process.exit(1);
315
+ throw new Error("Specify --start-turn, --turn, --latest, --tail, or --follow");
288
316
  }
289
317
  const params = {};
290
318
  params.start_turn = startTurn;
291
319
  if (endTurn)
292
320
  params.end_turn = endTurn;
293
321
  const data = await client.get(`/v1/projects/${id}/thread/messages`, params);
294
- print(getFormat(cmd), data);
322
+ if (opts.text) {
323
+ print(getFormat(cmd), safeOutput({ messages: assistantMessages(data) }));
324
+ }
325
+ else
326
+ print(getFormat(cmd), data);
295
327
  });
296
328
  async function followThreadStream(projectId, opts) {
297
329
  const timeout = positiveNumber(opts.timeout ?? "60", "--timeout") * 1000;
@@ -360,16 +392,64 @@ threadCmd
360
392
  ]);
361
393
  printTable(["Turn", "Name", "Status", "Model", "Credits", "Created"], rows);
362
394
  });
363
- export function threadInteraction(id, { taskId, chatId, turn, requireBuild } = {}) {
364
- const target = (taskId ? ` --task-id ${taskId}` : turn === undefined ? "" : ` --turn ${turn}`)
365
- + (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(" ");
366
442
  return {
443
+ workflow: workflowResult({ status: "running", task_id: taskId, turn: { turn, chat_id: chatId } }),
367
444
  monitoring_required: true,
368
- wait_command: `enter-cli --output json thread wait ${id}${target}`,
369
- watch_command: `enter-cli thread watch ${id}${target}`,
370
- status_command: `enter-cli --output json thread status ${id}${target}`,
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"}`,
450
+ status_command: `enter-cli --output json thread status ${id}${target} --timeout 10`,
371
451
  follow_up_command: `enter-cli --output json thread chat ${id}${chatId ? ` --chat-id ${chatId}` : ""} --file <message-file>`,
372
- instructions: "Accepted or timeout is not completion. Follow this task until blocked, failed or completed. Forward questions unchanged; approve only within existing authorization. Watching never approves or cancels remote work. Hosts own background scheduling.",
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.",
373
453
  };
374
454
  }
375
455
  function positiveNumber(value, option, integer = false) {
@@ -380,15 +460,19 @@ function positiveNumber(value, option, integer = false) {
380
460
  return number;
381
461
  }
382
462
  async function readThreadSnapshot(id, turnNumber, signal, taskId, chatId) {
383
- 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;
384
467
  const turns = data.turns ?? [];
385
468
  const selected = taskId ? turns.find(t => t.id === taskId || t.task_id === taskId)
386
469
  : turnNumber === undefined ? turns[0] : turns.find(t => Number(t.turn) === turnNumber);
387
470
  if (!selected && taskId) {
388
471
  const queue = await client.get(`/v1/projects/${id}/thread/tasks`, { simple: "true", ...(chatId ? { chat_id: chatId } : {}) }, signal);
389
- return { status: queue.task_ids?.includes(taskId) ? "queued" : "unknown", project_id: id, turn: null,
390
- reason: queue.task_ids?.includes(taskId) ? "task_queued" : "task_not_observed",
391
- ...(!queue.task_ids?.includes(taskId) ? {
472
+ const queued = queue.task_ids?.includes(taskId);
473
+ return { status: queued ? "queued" : "unknown", project_id: id, turn: null,
474
+ reason: queued ? "task_queued" : "task_not_observed",
475
+ ...(!queued ? {
392
476
  correlation: "unavailable",
393
477
  project_activity: turns[0] ? pick(turns[0], ["id", "turn", "status", "chat_id", "commit_id"]) : null,
394
478
  project_status_command: `enter-cli --output json thread status ${id}${chatId ? ` --chat-id ${chatId}` : ""}`,
@@ -403,7 +487,10 @@ async function readThreadSnapshot(id, turnNumber, signal, taskId, chatId) {
403
487
  if (["cancelled", "error", "failed"].includes(status)) {
404
488
  return { ...base, status: "failed", reason: `turn_${status}` };
405
489
  }
406
- 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));
407
494
  if (pending.length) {
408
495
  const context = { signal };
409
496
  const actions = await Promise.all(pending.map(async (a) => {
@@ -435,22 +522,32 @@ async function readThreadSnapshot(id, turnNumber, signal, taskId, chatId) {
435
522
  return { ...base, status: "completed", project: { ...project, lifecycle_status: resolveLifecycleStatus(project) } };
436
523
  }
437
524
  function projectSnapshot(snapshot, compact = false) {
525
+ const workflow = workflowResult(snapshot);
438
526
  const result = { ...snapshot };
439
- if (compact && snapshot.turn)
527
+ if (compact && snapshot.turn) {
440
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
+ }
532
+ if (snapshot.status === "completed" && snapshot.turn) {
533
+ result.messages_command = `enter-cli --output json thread messages ${snapshot.project_id} --turn ${snapshot.turn.turn} --text`;
534
+ }
441
535
  if (snapshot.project) {
442
536
  const p = snapshot.project;
443
537
  if (compact)
444
538
  result.project = pick(p, ["project_id", "name", "status", "lifecycle_status", "commit", "commit_turn", "preview_url", "publish_url", "build_status"]);
445
- const build = p.build_status;
446
- result.build_matches_turn = Boolean(snapshot.turn?.commit_id && build?.commit_id === snapshot.turn.commit_id);
539
+ result.build_matches_turn = workflow.build?.matches_task ?? false;
447
540
  const supabase = p.supabase;
448
541
  result.integrations = { cloud: supabase?.status ?? "unknown", ai: p.ai_connection_state ?? (p.ai_capability_enabled === true ? "enabled" : "unknown") };
449
542
  }
450
- return result;
543
+ result.workflow = workflow;
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 };
451
548
  }
452
549
  // Owns observation and lifetime only; callers choose JSON or NDJSON rendering.
453
- async function observeThread(id, opts, wait, onSnapshot) {
550
+ async function observeThread(id, opts, wait, onSnapshot, continuous = false) {
454
551
  let target = opts.turn === undefined ? undefined : positiveNumber(opts.turn, "--turn", true);
455
552
  if (opts.taskId && target !== undefined)
456
553
  throw new Error("Use only one of --task-id and --turn");
@@ -458,25 +555,35 @@ async function observeThread(id, opts, wait, onSnapshot) {
458
555
  throw new Error("--transport must be auto or poll");
459
556
  if (opts.cursor && !/^\d+-\d+$/.test(opts.cursor))
460
557
  throw new Error("--cursor must be an event ID such as 123-0");
461
- 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;
462
565
  if (timeoutMs > 2147483647)
463
566
  throw new Error("--timeout is too large");
464
567
  const controller = new AbortController();
465
- const timer = setTimeout(() => controller.abort(), timeoutMs);
568
+ const timer = timeoutMs === 0 ? undefined : setTimeout(() => controller.abort(), timeoutMs);
466
569
  const interrupt = () => controller.abort("SIGINT");
467
570
  const terminate = () => controller.abort("SIGTERM");
468
571
  process.once("SIGINT", interrupt);
469
572
  process.once("SIGTERM", terminate);
470
573
  let last = { status: "unknown", project_id: id, turn: null };
471
574
  let querying = false;
575
+ let hasSnapshot = false;
576
+ let progressPromise;
577
+ let progressRead;
472
578
  let stream;
473
579
  let reconcileUntil = 0;
474
580
  const emit = (extra = {}, final = false) => onSnapshot({
475
581
  ...last,
476
582
  ...(["idle", "running", "queued", "pending", "unknown"].includes(last.status)
477
- ? 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 }) : {}),
478
585
  ...(opts.taskId ? { task_id: opts.taskId } : {}),
479
- ...(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,
480
587
  }, final);
481
588
  const interruption = () => {
482
589
  const reason = controller.signal.reason;
@@ -491,7 +598,32 @@ async function observeThread(id, opts, wait, onSnapshot) {
491
598
  throw stream.failure;
492
599
  const revision = stream?.revision ?? 0;
493
600
  querying = true;
494
- last = await readThreadSnapshot(id, target, controller.signal, opts.taskId, opts.chatId);
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);
606
+ try {
607
+ last = await readThreadSnapshot(id, target, queryController.signal, opts.taskId, opts.chatId);
608
+ last.observed_at = new Date().toISOString();
609
+ hasSnapshot = true;
610
+ }
611
+ catch (error) {
612
+ if (queryController.signal.aborted && !controller.signal.aborted) {
613
+ error = new RequestError("QUERY_TIMEOUT", "Enter status query exceeded its deadline.", true);
614
+ }
615
+ if (!wait || controller.signal.aborted || !(error instanceof RequestError) || !error.retryable)
616
+ throw error;
617
+ querying = false;
618
+ emit({ ...errorEnvelope(error), observation_retrying: true, last_observed: hasSnapshot });
619
+ await delay(1000, undefined, { signal: controller.signal });
620
+ continue;
621
+ }
622
+ finally {
623
+ queryController.abort();
624
+ clearTimeout(queryTimer);
625
+ controller.signal.removeEventListener("abort", abortQuery);
626
+ }
495
627
  if (opts.requireBuild && last.status === "completed") {
496
628
  const build = last.project?.build_status;
497
629
  const matches = Boolean(last.turn?.commit_id && build?.commit_id === last.turn.commit_id);
@@ -501,6 +633,30 @@ async function observeThread(id, opts, wait, onSnapshot) {
501
633
  last = { ...last, status: "failed", reason: "build_failed" };
502
634
  }
503
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;
504
660
  if (target === undefined && last.turn && !opts.taskId)
505
661
  target = positiveNumber(String(last.turn.turn), "server turn", true);
506
662
  // A newly accepted task can be temporarily absent from both reads. Keep
@@ -510,7 +666,9 @@ async function observeThread(id, opts, wait, onSnapshot) {
510
666
  emit({}, true);
511
667
  return last.status === "failed" || last.status === "unknown" ? 1 : 0;
512
668
  }
513
- if (!stream && opts.transport !== "poll") {
669
+ if (controller.signal.aborted)
670
+ break;
671
+ if (!stream && opts.transport === "auto") {
514
672
  stream = new ThreadEvents(id, {
515
673
  signal: controller.signal, turn: target ?? (last.turn ? Number(last.turn.turn) : undefined),
516
674
  chatId: opts.chatId ?? last.turn?.chat_id, cursor: opts.cursor, stateOnly: true,
@@ -518,16 +676,24 @@ async function observeThread(id, opts, wait, onSnapshot) {
518
676
  reconcileUntil = Date.now() + 1500; },
519
677
  });
520
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
+ }
521
685
  emit();
522
686
  if (stream)
523
687
  await stream.wait(revision, Date.now() < reconcileUntil ? 250 : stream.mode === "websocket" ? 30000 : 2000);
524
- else
525
- 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
+ }
526
692
  }
527
693
  const interrupted = interruption();
528
694
  if (interrupted !== undefined)
529
695
  return interrupted;
530
- emit({ wait_timed_out: true, query_timed_out: false }, true);
696
+ emit({ wait_timed_out: true, query_timed_out: false, last_observed: hasSnapshot }, true);
531
697
  return 2;
532
698
  }
533
699
  catch (error) {
@@ -535,13 +701,20 @@ async function observeThread(id, opts, wait, onSnapshot) {
535
701
  if (interrupted !== undefined)
536
702
  return interrupted;
537
703
  if (!controller.signal.aborted) {
538
- 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);
539
707
  return 1;
540
708
  }
541
- 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);
542
714
  return wait && !querying ? 2 : 1;
543
715
  }
544
716
  finally {
717
+ controller.abort();
545
718
  stream?.close();
546
719
  clearTimeout(timer);
547
720
  process.removeListener("SIGINT", interrupt);
@@ -551,38 +724,68 @@ async function observeThread(id, opts, wait, onSnapshot) {
551
724
  export async function reportThread(id, opts, cmd, wait, watch = false) {
552
725
  if (opts.full && opts.compact)
553
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);
554
729
  let signature = "";
730
+ let lastOutputAt = 0;
731
+ let lastState = "";
732
+ let reportedProgress = opts.afterProgress;
555
733
  process.exitCode = await observeThread(id, opts, wait, (snapshot, final) => {
556
- 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);
557
747
  if (!watch) {
558
- if (final)
559
- print(getFormat(cmd), safeOutput(data));
748
+ print(getFormat(cmd), safeOutput(data));
560
749
  return;
561
750
  }
562
751
  // Cursor-only changes should not flood agents with identical snapshots.
563
- const next = JSON.stringify({ ...data, cursor: undefined });
564
- if (next !== signature || final) {
565
- 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");
566
758
  signature = next;
759
+ lastOutputAt = now;
760
+ if (snapshot.progress?.available && snapshot.progress.messages.length)
761
+ reportedProgress = snapshot.progress.revision;
567
762
  }
568
- });
763
+ }, watch);
569
764
  }
570
765
  for (const [name, timeout, description] of [
571
766
  ["status", "30", "Read one task snapshot; never wait for completion"],
572
- ["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"],
573
768
  ["watch", "60", "Stream meaningful state changes as NDJSON; exit on pending actions or terminal state"],
574
769
  ]) {
575
- threadCmd.command(`${name} <project_id>`).description(description)
770
+ const command = threadCmd.command(`${name} <project_id>`).description(description)
576
771
  .option("--turn <n>", "Select a fixed turn number")
577
772
  .option("--task-id <id>", "Follow exactly the task returned by chat/approve, including queue time")
578
773
  .option("--chat-id <id>", "Scope turn lookup and events to a chat")
579
774
  .option("--cursor <id>", "Resume stream after an event ID")
580
- .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")
581
778
  .option("--require-build", "Require a matching successful build before reporting completion")
582
- .option("--compact", "Return only monitoring fields (default for watch)")
583
- .option("--full", "Include full sanitized metadata (default for status/wait)")
584
- .option("--timeout <seconds>", "Bound the entire call without cancelling Enter", timeout)
585
- .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");
586
789
  }
587
790
  threadCmd
588
791
  .command("diff <project_id> <turn_number>")
@@ -600,7 +803,7 @@ threadCmd
600
803
  else {
601
804
  writeFileSync(path, JSON.stringify(data, null, 2));
602
805
  }
603
- printMessage(`Diff written to ${path}`);
806
+ printResult(getFormat(cmd), { path }, `Diff written to ${path}`);
604
807
  return;
605
808
  }
606
809
  print(getFormat(cmd), data);
@@ -608,10 +811,11 @@ threadCmd
608
811
  threadCmd
609
812
  .command("cancel <project_id>")
610
813
  .description("Cancel the currently running turn. No-op if nothing is running.")
611
- .action(async (id, _opts, cmd) => {
814
+ .option("--chat-id <id>", "Scope cancellation to this chat")
815
+ .action(async (id, opts, cmd) => {
612
816
  // Pre-check the latest turn — the server returns code 1000 even when
613
817
  // nothing is running, which is misleading. We want a clean idempotent no-op.
614
- const turnsResp = await client.get(`/v1/projects/${id}/thread/turns`);
818
+ const turnsResp = await client.get(`/v1/projects/${id}/thread/turns`, opts.chatId ? { chat_id: opts.chatId } : undefined);
615
819
  const turns = turnsResp.turns ?? [];
616
820
  const latest = turns[0];
617
821
  const isRunning = latest && !TERMINAL_TURN_STATUSES.has(String(latest.status ?? ""));
@@ -622,11 +826,13 @@ threadCmd
622
826
  reason: "no_running_turn",
623
827
  latest_turn: latest?.turn ?? null,
624
828
  latest_status: latest?.status ?? null,
829
+ tasks_command: `enter-cli --output json thread tasks ${id}${opts.chatId ? ` --chat-id ${opts.chatId}` : ""}`,
625
830
  }, `Nothing to cancel (latest turn ${latest?.turn ?? "?"} status: ${latest?.status ?? "unknown"}).`);
626
831
  return;
627
832
  }
628
- await client.post(`/v1/projects/${id}/thread/cancel`);
629
- printResult(format, { cancelled: true, turn: latest.turn }, `Cancelled turn ${latest.turn}.`);
833
+ const chatId = opts.chatId ?? latest.chat_id;
834
+ await client.post(`/v1/projects/${id}/thread/cancel`, chatId ? { chat_id: chatId } : undefined);
835
+ printResult(format, { cancelled: true, turn: latest.turn, ...(chatId ? { chat_id: chatId } : {}) }, `Cancelled turn ${latest.turn}.`);
630
836
  });
631
837
  threadCmd
632
838
  .command("restore <project_id>")
@@ -756,7 +962,7 @@ threadCmd
756
962
  .option("--secret-value-stdin", "Read the secret value from stdin instead of process arguments")
757
963
  .option("--auth-config-stdin", "Read configuration JSON for the action's auth provider from stdin; credentials never go into the approval response")
758
964
  .option("--tool-result <result>", "Custom tool result string")
759
- .option("--answers <json>", 'Answers for ask_user_question, JSON: \'{"Q text": {"selected_options": ["A"], "other_text": ""}}\'')
965
+ .option("--answers <json>", `Answers for ask_user_question. ${ANSWERS_FORMAT}`)
760
966
  .option("--skip-answers", "Skip all questions for ask_user_question (sets skipped: true)")
761
967
  .action(async (projectId, actionIdArg, opts, cmd) => {
762
968
  const format = getFormat(cmd);
@@ -792,7 +998,17 @@ threadCmd
792
998
  }
793
999
  const data = await client.get(configPath);
794
1000
  if (data.config?.providers?.[provider]?.enabled !== true) {
795
- throw new Error(`Auth provider ${provider} is not enabled. Configure it through Enter's secure form or --auth-config-stdin before approval.`);
1001
+ print(format, {
1002
+ status: "blocked", reason: "auth_provider_configuration_required",
1003
+ project_id: projectId, approved: false,
1004
+ actions: [{
1005
+ action_id: action.action_id, tool_name: action.tool_name, turn: action.turn,
1006
+ input_kind: "auth_provider", ...authProviderInput(provider),
1007
+ approve_command: buildApproveCommand(projectId, action),
1008
+ }],
1009
+ next_step: "Provide the missing fields via --auth-config-stdin, or save them in Enter's provider form and query thread status. Continue with existing authorized input without asking the user to enter it again.",
1010
+ });
1011
+ return;
796
1012
  }
797
1013
  // The backend additionally verifies stored credentials and the card's
798
1014
  // ExpectedProvider before claiming the action. Never send credentials here.
@@ -819,10 +1035,10 @@ threadCmd
819
1035
  if (answers) {
820
1036
  for (const answer of Object.values(answers)) {
821
1037
  if (!answer || typeof answer !== "object" || Array.isArray(answer))
822
- throw new Error("Each answer must be an object");
1038
+ throw new Error(`Invalid --answers: each answer must be an object. ${ANSWERS_FORMAT}`);
823
1039
  const value = answer;
824
1040
  if (!Array.isArray(value.selected_options) || !value.selected_options.every(item => typeof item === "string") || (value.other_text !== undefined && typeof value.other_text !== "string")) {
825
- throw new Error("Each answer requires selected_options as a string array and optional other_text as a string");
1041
+ throw new Error(`Invalid --answers: selected_options must be a string array and other_text an optional string. ${ANSWERS_FORMAT}`);
826
1042
  }
827
1043
  }
828
1044
  }