@united-workforce/cli 0.6.1 → 0.8.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.
Files changed (167) hide show
  1. package/README.md +120 -5
  2. package/dist/.build-fingerprint +1 -1
  3. package/dist/__tests__/agent-resolution-llm-free.test.js +9 -2
  4. package/dist/__tests__/agent-resolution-llm-free.test.js.map +1 -1
  5. package/dist/__tests__/broker-prompt.test.d.ts +10 -0
  6. package/dist/__tests__/broker-prompt.test.d.ts.map +1 -0
  7. package/dist/__tests__/broker-prompt.test.js +129 -0
  8. package/dist/__tests__/broker-prompt.test.js.map +1 -0
  9. package/dist/__tests__/broker-step-active-turns.test.d.ts +20 -0
  10. package/dist/__tests__/broker-step-active-turns.test.d.ts.map +1 -0
  11. package/dist/__tests__/broker-step-active-turns.test.js +428 -0
  12. package/dist/__tests__/broker-step-active-turns.test.js.map +1 -0
  13. package/dist/__tests__/broker-step-turn-chain-phase2.test.d.ts +13 -0
  14. package/dist/__tests__/broker-step-turn-chain-phase2.test.d.ts.map +1 -0
  15. package/dist/__tests__/broker-step-turn-chain-phase2.test.js +429 -0
  16. package/dist/__tests__/broker-step-turn-chain-phase2.test.js.map +1 -0
  17. package/dist/__tests__/config.test.js +33 -37
  18. package/dist/__tests__/config.test.js.map +1 -1
  19. package/dist/__tests__/e2e-broker-step-suspend.test.d.ts +18 -0
  20. package/dist/__tests__/e2e-broker-step-suspend.test.d.ts.map +1 -0
  21. package/dist/__tests__/e2e-broker-step-suspend.test.js +313 -0
  22. package/dist/__tests__/e2e-broker-step-suspend.test.js.map +1 -0
  23. package/dist/__tests__/e2e-broker-step.test.d.ts +13 -0
  24. package/dist/__tests__/e2e-broker-step.test.d.ts.map +1 -0
  25. package/dist/__tests__/e2e-broker-step.test.js +278 -0
  26. package/dist/__tests__/e2e-broker-step.test.js.map +1 -0
  27. package/dist/__tests__/e2e-mock-agent.test.js +1 -1
  28. package/dist/__tests__/e2e-mock-agent.test.js.map +1 -1
  29. package/dist/__tests__/e2e-thread-resume-timeout-suspend.test.d.ts +28 -0
  30. package/dist/__tests__/e2e-thread-resume-timeout-suspend.test.d.ts.map +1 -0
  31. package/dist/__tests__/e2e-thread-resume-timeout-suspend.test.js +322 -0
  32. package/dist/__tests__/e2e-thread-resume-timeout-suspend.test.js.map +1 -0
  33. package/dist/__tests__/log-tag-validity.test.d.ts +2 -0
  34. package/dist/__tests__/log-tag-validity.test.d.ts.map +1 -0
  35. package/dist/__tests__/log-tag-validity.test.js +110 -0
  36. package/dist/__tests__/log-tag-validity.test.js.map +1 -0
  37. package/dist/__tests__/setup-agent-discovery.test.js +35 -23
  38. package/dist/__tests__/setup-agent-discovery.test.js.map +1 -1
  39. package/dist/__tests__/setup-no-llm.test.js +5 -2
  40. package/dist/__tests__/setup-no-llm.test.js.map +1 -1
  41. package/dist/__tests__/step-ask.test.js +9 -6
  42. package/dist/__tests__/step-ask.test.js.map +1 -1
  43. package/dist/__tests__/step-show-json.test.js +5 -5
  44. package/dist/__tests__/step-show-json.test.js.map +1 -1
  45. package/dist/__tests__/step-show-text.test.d.ts +2 -0
  46. package/dist/__tests__/step-show-text.test.d.ts.map +1 -0
  47. package/dist/__tests__/step-show-text.test.js +192 -0
  48. package/dist/__tests__/step-show-text.test.js.map +1 -0
  49. package/dist/__tests__/step-turns-cli-subprocess.test.d.ts +21 -0
  50. package/dist/__tests__/step-turns-cli-subprocess.test.d.ts.map +1 -0
  51. package/dist/__tests__/step-turns-cli-subprocess.test.js +356 -0
  52. package/dist/__tests__/step-turns-cli-subprocess.test.js.map +1 -0
  53. package/dist/__tests__/step-turns-panorama-phase3.test.d.ts +21 -0
  54. package/dist/__tests__/step-turns-panorama-phase3.test.d.ts.map +1 -0
  55. package/dist/__tests__/step-turns-panorama-phase3.test.js +476 -0
  56. package/dist/__tests__/step-turns-panorama-phase3.test.js.map +1 -0
  57. package/dist/__tests__/step-turns.test.d.ts +24 -0
  58. package/dist/__tests__/step-turns.test.d.ts.map +1 -0
  59. package/dist/__tests__/step-turns.test.js +646 -0
  60. package/dist/__tests__/step-turns.test.js.map +1 -0
  61. package/dist/__tests__/store-turn-chain.test.d.ts +2 -0
  62. package/dist/__tests__/store-turn-chain.test.d.ts.map +1 -0
  63. package/dist/__tests__/store-turn-chain.test.js +341 -0
  64. package/dist/__tests__/store-turn-chain.test.js.map +1 -0
  65. package/dist/__tests__/thread-agent-failure-suspended.test.js +3 -3
  66. package/dist/__tests__/thread-agent-failure-suspended.test.js.map +1 -1
  67. package/dist/__tests__/thread-list-limit-offset.test.d.ts +24 -0
  68. package/dist/__tests__/thread-list-limit-offset.test.d.ts.map +1 -0
  69. package/dist/__tests__/thread-list-limit-offset.test.js +254 -0
  70. package/dist/__tests__/thread-list-limit-offset.test.js.map +1 -0
  71. package/dist/__tests__/thread-list-template-ms-date.test.js +7 -2
  72. package/dist/__tests__/thread-list-template-ms-date.test.js.map +1 -1
  73. package/dist/__tests__/thread-poke.test.js +6 -6
  74. package/dist/__tests__/thread-poke.test.js.map +1 -1
  75. package/dist/__tests__/thread-resume.test.js +2 -2
  76. package/dist/__tests__/thread-resume.test.js.map +1 -1
  77. package/dist/__tests__/thread-suspend-step.test.js +1 -1
  78. package/dist/__tests__/thread-suspend-step.test.js.map +1 -1
  79. package/dist/__tests__/thread.test.js +28 -14
  80. package/dist/__tests__/thread.test.js.map +1 -1
  81. package/dist/cli.js +910 -344
  82. package/dist/cli.js.map +1 -1
  83. package/dist/commands/broker-step.d.ts +117 -0
  84. package/dist/commands/broker-step.d.ts.map +1 -0
  85. package/dist/commands/broker-step.js +654 -0
  86. package/dist/commands/broker-step.js.map +1 -0
  87. package/dist/commands/config.d.ts.map +1 -1
  88. package/dist/commands/config.js +2 -23
  89. package/dist/commands/config.js.map +1 -1
  90. package/dist/commands/prompt.d.ts.map +1 -1
  91. package/dist/commands/prompt.js +43 -51
  92. package/dist/commands/prompt.js.map +1 -1
  93. package/dist/commands/setup.d.ts +6 -4
  94. package/dist/commands/setup.d.ts.map +1 -1
  95. package/dist/commands/setup.js +24 -27
  96. package/dist/commands/setup.js.map +1 -1
  97. package/dist/commands/step.d.ts +54 -6
  98. package/dist/commands/step.d.ts.map +1 -1
  99. package/dist/commands/step.js +484 -134
  100. package/dist/commands/step.js.map +1 -1
  101. package/dist/commands/thread.d.ts +4 -0
  102. package/dist/commands/thread.d.ts.map +1 -1
  103. package/dist/commands/thread.js +77 -151
  104. package/dist/commands/thread.js.map +1 -1
  105. package/dist/output-mappers.d.ts +8 -0
  106. package/dist/output-mappers.d.ts.map +1 -1
  107. package/dist/output-mappers.js +72 -18
  108. package/dist/output-mappers.js.map +1 -1
  109. package/dist/schemas.d.ts +3 -0
  110. package/dist/schemas.d.ts.map +1 -1
  111. package/dist/schemas.js +17 -3
  112. package/dist/schemas.js.map +1 -1
  113. package/dist/store.d.ts +147 -1
  114. package/dist/store.d.ts.map +1 -1
  115. package/dist/store.js +254 -1
  116. package/dist/store.js.map +1 -1
  117. package/dist/text-renderers.d.ts.map +1 -1
  118. package/dist/text-renderers.js +27 -2
  119. package/dist/text-renderers.js.map +1 -1
  120. package/package.json +7 -5
  121. package/src/__tests__/agent-resolution-llm-free.test.ts +14 -2
  122. package/src/__tests__/broker-prompt.test.ts +142 -0
  123. package/src/__tests__/broker-step-active-turns.test.ts +509 -0
  124. package/src/__tests__/broker-step-turn-chain-phase2.test.ts +525 -0
  125. package/src/__tests__/config.test.ts +35 -39
  126. package/src/__tests__/e2e-broker-step-suspend.test.ts +351 -0
  127. package/src/__tests__/e2e-broker-step.test.ts +320 -0
  128. package/src/__tests__/e2e-mock-agent.test.ts +1 -1
  129. package/src/__tests__/e2e-thread-resume-timeout-suspend.test.ts +360 -0
  130. package/src/__tests__/log-tag-validity.test.ts +124 -0
  131. package/src/__tests__/setup-agent-discovery.test.ts +35 -23
  132. package/src/__tests__/setup-no-llm.test.ts +5 -2
  133. package/src/__tests__/step-ask.test.ts +9 -6
  134. package/src/__tests__/step-show-json.test.ts +5 -5
  135. package/src/__tests__/step-show-text.test.ts +236 -0
  136. package/src/__tests__/step-turns-cli-subprocess.test.ts +411 -0
  137. package/src/__tests__/step-turns-panorama-phase3.test.ts +579 -0
  138. package/src/__tests__/step-turns.test.ts +734 -0
  139. package/src/__tests__/store-turn-chain.test.ts +386 -0
  140. package/src/__tests__/thread-agent-failure-suspended.test.ts +3 -3
  141. package/src/__tests__/thread-list-limit-offset.test.ts +305 -0
  142. package/src/__tests__/thread-list-template-ms-date.test.ts +7 -2
  143. package/src/__tests__/thread-poke.test.ts +6 -6
  144. package/src/__tests__/thread-resume.test.ts +2 -2
  145. package/src/__tests__/thread-suspend-step.test.ts +1 -1
  146. package/src/__tests__/thread.test.ts +29 -15
  147. package/src/cli.ts +1056 -483
  148. package/src/commands/broker-step.ts +913 -0
  149. package/src/commands/config.ts +2 -24
  150. package/src/commands/prompt.ts +43 -51
  151. package/src/commands/setup.ts +25 -29
  152. package/src/commands/step.ts +645 -176
  153. package/src/commands/thread.ts +87 -192
  154. package/src/output-mappers.ts +99 -21
  155. package/src/schemas.ts +32 -2
  156. package/src/store.ts +297 -2
  157. package/src/text-renderers.ts +35 -2
  158. package/dist/__tests__/adapter-json-roundtrip.test.d.ts +0 -2
  159. package/dist/__tests__/adapter-json-roundtrip.test.d.ts.map +0 -1
  160. package/dist/__tests__/adapter-json-roundtrip.test.js +0 -160
  161. package/dist/__tests__/adapter-json-roundtrip.test.js.map +0 -1
  162. package/dist/__tests__/spawn-agent-json.test.d.ts +0 -2
  163. package/dist/__tests__/spawn-agent-json.test.d.ts.map +0 -1
  164. package/dist/__tests__/spawn-agent-json.test.js +0 -79
  165. package/dist/__tests__/spawn-agent-json.test.js.map +0 -1
  166. package/src/__tests__/adapter-json-roundtrip.test.ts +0 -193
  167. package/src/__tests__/spawn-agent-json.test.ts +0 -100
@@ -1,7 +1,6 @@
1
- import { execFileSync } from "node:child_process";
2
1
  import { createLogger, generateUlid } from "@united-workforce/util";
3
- import { getAskSessionId, loadWorkflowConfig, setAskSessionId } from "@united-workforce/util-agent";
4
- import { createUwfStore, setThread } from "../store.js";
2
+ import { isThreadRunning } from "../background/index.js";
3
+ import { createUwfStore, getActiveStep, getActiveTurnHead, getThread, readActiveTurnRoles, readActiveTurns, setThread, turnsOfStep, } from "../store.js";
5
4
  import { collectOrderedSteps, expandDeep, expandOutput, fail, resolveHeadHash, walkChain, } from "./shared.js";
6
5
  const log = createLogger({ sink: { kind: "stderr" } });
7
6
  /**
@@ -128,7 +127,14 @@ export async function cmdStepList(storageRoot, threadId) {
128
127
  };
129
128
  }
130
129
  /**
131
- * Show details of a specific step (previously: thread step-details)
130
+ * Show details of a specific step (previously: thread step-details).
131
+ *
132
+ * Returns a merged object that combines StepNode metadata (role / agent /
133
+ * timing / usage) with the expanded broker-detail payload so callers can
134
+ * read both layers in one envelope. The detail node by itself only carries
135
+ * `{ sessionId, duration, turnCount, turns }` — without merging in the
136
+ * StepNode metadata, `step show` would render empty `Role` / `Agent` /
137
+ * `Status` / `-` `Duration` (issue #392).
132
138
  */
133
139
  export async function cmdStepShow(storageRoot, stepHash) {
134
140
  const uwf = await createUwfStore(storageRoot);
@@ -143,7 +149,34 @@ export async function cmdStepShow(storageRoot, stepHash) {
143
149
  if (!payload.detail) {
144
150
  fail(`step ${stepHash} has no detail`);
145
151
  }
146
- return expandDeep(uwf.store, payload.detail);
152
+ const detail = expandDeep(uwf.store, payload.detail);
153
+ const output = expandOutput(uwf, payload.output);
154
+ const status = output !== null &&
155
+ typeof output === "object" &&
156
+ !Array.isArray(output) &&
157
+ typeof output.$status === "string"
158
+ ? output.$status
159
+ : "";
160
+ const startedAtMs = typeof payload.startedAtMs === "number" && Number.isFinite(payload.startedAtMs)
161
+ ? payload.startedAtMs
162
+ : null;
163
+ const completedAtMs = typeof payload.completedAtMs === "number" && Number.isFinite(payload.completedAtMs)
164
+ ? payload.completedAtMs
165
+ : null;
166
+ const durationMs = startedAtMs !== null && completedAtMs !== null && completedAtMs >= startedAtMs
167
+ ? completedAtMs - startedAtMs
168
+ : null;
169
+ return {
170
+ hash: stepHash,
171
+ role: payload.role,
172
+ agent: payload.agent,
173
+ status,
174
+ startedAtMs,
175
+ completedAtMs,
176
+ durationMs,
177
+ usage: payload.usage ?? null,
178
+ detail,
179
+ };
147
180
  }
148
181
  /**
149
182
  * Fork a thread from a specific step (previously: thread fork)
@@ -343,159 +376,476 @@ export async function cmdStepRead(storageRoot, stepHash, quota, showPrompt) {
343
376
  const selectedTurns = selectTurnsForQuota(turnData, availableQuota);
344
377
  return formatStepMarkdown(stepHash, payload.role, payload.agent, turnData, selectedTurns);
345
378
  }
346
- // ── step ask ────────────────────────────────────────────────────────────────
347
- function parseAgentOverride(override) {
348
- const parts = override
349
- .trim()
350
- .split(/\s+/)
351
- .filter((p) => p.length > 0);
352
- const command = parts[0];
353
- if (command === undefined) {
354
- fail("agent override must not be empty");
355
- }
356
- return { command, args: parts.slice(1) };
357
- }
358
- function resolveAskAgentConfig(config, workflow, role, agentOverride, recordedAgent) {
359
- if (agentOverride !== null) {
360
- const fromAlias = config.agents[agentOverride];
361
- if (fromAlias !== undefined) {
362
- return fromAlias;
379
+ // ── step turns ────────────────────────────────────────────────────────────────
380
+ //
381
+ // Phase 4 (#400) / #409 — the consumer side of the realtime-turns RFC. `step
382
+ // turns <thread-id>` renders the **whole-thread turn panorama**: it walks the
383
+ // entire thread chain (reusing the SAME `walkChain` + `collectOrderedSteps`
384
+ // infrastructure as `cmdStepList`) and shows every step's turns in chronological
385
+ // order, each turn attributed to its owning role/step.
386
+ //
387
+ // Per-step turn sourcing (active-var precedence, scoped to each step's role):
388
+ // - the in-flight step (its `@uwf/active-turns/<tid>/<role>` var still present)
389
+ // → read the live active var and mark the step `🔄 进行中`;
390
+ // - every completed step → read its own immutable `detail.turns` and mark `✓`.
391
+ // Both sources are a `CasRef[]` of pure `{role, content}` turn nodes, so per-turn
392
+ // rendering reuses the SAME `loadTurnData` → `formatTurnBody` pipeline as
393
+ // `step read` — a turn block here is byte-identical to `step read` for that step.
394
+ //
395
+ // `--role X` filters the panorama to that role's steps (across the whole chain);
396
+ // `--limit`/`--offset` paginate the flattened cross-step turn sequence (filter
397
+ // first, then paginate). Default is full, untruncated output. Because turns are
398
+ // always sourced per-step, role isolation (#408) falls out structurally — the
399
+ // head-only `readHeadDetailTurns` role-guard hack is obsolete.
400
+ /** Default poll interval for `--live` (ms). Small + fixed; injectable for tests. */
401
+ export const STEP_TURNS_POLL_INTERVAL_MS = 400;
402
+ /** Fill optional CmdStepTurnsOptions fields with their runtime defaults. */
403
+ function resolveStepTurnsOptions(storageRoot, threadId, options) {
404
+ return {
405
+ role: options.role ?? null,
406
+ live: options.live,
407
+ limit: options.limit ?? null,
408
+ offset: options.offset ?? 0,
409
+ pollIntervalMs: options.pollIntervalMs ?? null,
410
+ onChunk: options.onChunk ?? null,
411
+ sleep: options.sleep ?? null,
412
+ isRunning: options.isRunning ?? (async () => (await isThreadRunning(storageRoot, threadId)) !== null),
413
+ };
414
+ }
415
+ /**
416
+ * Walk the thread chain from `headHash` and return the **newest** step whose
417
+ * `role === role`'s immutable `detail.turns`, or `[]` when no step on the chain
418
+ * has that role. Used by the `--live` exit reconcile to flush the followed role's
419
+ * own solidified turns without ever surfacing a *different* role's turns: in a
420
+ * multi-step run the head may have advanced past the followed step to another
421
+ * role, so reconciling against `head` blindly (the pre-#409 `readHeadDetailTurns`)
422
+ * could leak the next role's turns. Scoping to the followed role's own step on
423
+ * the chain is the live counterpart of the non-live per-step sourcing.
424
+ */
425
+ function readRoleDetailTurnsFromChain(uwf, headHash, role) {
426
+ let hash = headHash;
427
+ while (hash !== null) {
428
+ const node = uwf.store.cas.get(hash);
429
+ if (node === null || node.type !== uwf.schemas.stepNode) {
430
+ break;
363
431
  }
364
- return parseAgentOverride(agentOverride);
365
- }
366
- // Try to resolve via the recorded agent name as a config alias.
367
- const fromRecorded = config.agents[recordedAgent];
368
- if (fromRecorded !== undefined) {
369
- return fromRecorded;
370
- }
371
- // Fall back to default agent for the workflow / role.
372
- if (workflow !== null && config.agentOverrides !== null) {
373
- const roleOverrides = config.agentOverrides[workflow.name];
374
- if (roleOverrides !== undefined && roleOverrides[role] !== undefined) {
375
- const alias = roleOverrides[role];
376
- const agentConfig = config.agents[alias];
377
- if (agentConfig !== undefined) {
378
- return agentConfig;
379
- }
432
+ const payload = node.payload;
433
+ if (payload.role === role) {
434
+ return readStepDetailTurns(uwf, hash);
380
435
  }
436
+ hash = payload.prev;
381
437
  }
382
- // Treat the recorded value as a raw command path.
383
- return parseAgentOverride(recordedAgent);
438
+ return [];
384
439
  }
385
440
  /**
386
- * Derive the agent name used for cache file partitioning from an executable
387
- * path or alias. Examples:
388
- * uwf-hermes → hermes
389
- * uwf-claude-code → claude-code
390
- * /tmp/mock-agent.sh mock
391
- * /usr/bin/agent → agent
441
+ * Read a specific step's immutable `detail.turns` (the ordered `CasRef[]` of its
442
+ * turn nodes). Returns `[]` for a non-StepNode, a step with no detail, or a
443
+ * detail whose `turns` is absent/malformed. Unlike `readHeadDetailTurns` this is
444
+ * role-agnostic — the caller already knows which step it is reading (the chain
445
+ * walk attributes each step to its own role), so no head-role guard is needed.
392
446
  */
393
- function deriveAgentName(commandPath) {
394
- const basename = commandPath.split(/[/\\]/).pop() ?? commandPath;
395
- // Strip a trailing extension (.sh, .js, .mjs, .cjs)
396
- const noExt = basename.replace(/\.(sh|js|mjs|cjs|ts)$/i, "");
397
- // Strip the `uwf-` prefix introduced by agentLabel().
398
- const noPrefix = noExt.startsWith("uwf-") ? noExt.slice(4) : noExt;
399
- // Strip the trailing `-agent` suffix used by tests / generic agent shells.
400
- const noSuffix = noPrefix.endsWith("-agent") ? noPrefix.slice(0, -"-agent".length) : noPrefix;
401
- return noSuffix === "" ? noExt : noSuffix;
402
- }
403
- function loadDetailNode(store, detailRef) {
404
- const detailNode = store.get(detailRef);
447
+ function readStepDetailTurns(uwf, stepHash) {
448
+ const node = uwf.store.cas.get(stepHash);
449
+ if (node === null || node.type !== uwf.schemas.stepNode) {
450
+ return [];
451
+ }
452
+ const payload = node.payload;
453
+ if (payload.detail === null) {
454
+ return [];
455
+ }
456
+ const detailNode = uwf.store.cas.get(payload.detail);
405
457
  if (detailNode === null) {
406
- fail(`detail node not found: ${detailRef}`);
458
+ return [];
459
+ }
460
+ const detail = detailNode.payload;
461
+ return Array.isArray(detail.turns) ? detail.turns : [];
462
+ }
463
+ /**
464
+ * Walk the step-start chain from a turn's owner backward via `prev` pointers.
465
+ * Returns step-starts in chronological order (oldest first).
466
+ */
467
+ function walkStepStartChain(uwf, turnHead) {
468
+ // First, find a step-start hash from any turn's owner
469
+ const turnChain = [];
470
+ let currentTurn = turnHead;
471
+ // Walk the turn chain to find all unique owners
472
+ const seenOwners = new Set();
473
+ const owners = [];
474
+ while (currentTurn !== null) {
475
+ turnChain.push(currentTurn);
476
+ const node = uwf.store.cas.get(currentTurn);
477
+ if (node === null)
478
+ break;
479
+ const payload = node.payload;
480
+ const owner = payload.owner ?? null;
481
+ if (owner !== null && !seenOwners.has(owner)) {
482
+ seenOwners.add(owner);
483
+ owners.push(owner);
484
+ }
485
+ currentTurn = payload.prev ?? null;
407
486
  }
408
- const payload = detailNode.payload;
409
- const sessionId = typeof payload.sessionId === "string" ? payload.sessionId : null;
410
- return { sessionId, payload };
411
- }
412
- function spawnAskAgent(agent, argv, cwd) {
413
- try {
414
- const stdout = execFileSync(agent.command, [...agent.args, ...argv], {
415
- encoding: "utf8",
416
- stdio: ["ignore", "pipe", "pipe"],
417
- maxBuffer: 50 * 1024 * 1024,
418
- cwd,
487
+ // Now walk the step-start chain to get them in order
488
+ // Find the newest step-start and walk backward via prev
489
+ if (owners.length === 0) {
490
+ return [];
491
+ }
492
+ // Use the owners we found and order by stepIndex
493
+ const stepStartsWithIndex = [];
494
+ for (const owner of owners) {
495
+ const node = uwf.store.cas.get(owner);
496
+ if (node === null || node.type !== uwf.schemas.stepStart)
497
+ continue;
498
+ const payload = node.payload;
499
+ stepStartsWithIndex.push({ hash: owner, index: payload.stepIndex });
500
+ }
501
+ // Sort by stepIndex to get chronological order
502
+ stepStartsWithIndex.sort((a, b) => a.index - b.index);
503
+ return stepStartsWithIndex.map((s) => s.hash);
504
+ }
505
+ /**
506
+ * Build the whole-thread turn panorama (#421 Phase 3): walk the step-start chain
507
+ * (via turn owner → step-start → prev) and produce one group per step in
508
+ * chronological order. Each turn is attributed to its owning step-start via the
509
+ * `owner` field.
510
+ *
511
+ * Phase 3 changes (root-causing #412):
512
+ * - Walks step-start chain instead of role-keyed active vars
513
+ * - Each segment's turns sourced via `turnsOfStep(turnHead, stepStartHash)`
514
+ * - In-flight detection: active-step matches step-start AND no step-complete
515
+ * - edgePrompt readable directly from step-start
516
+ *
517
+ * In-flight step detection:
518
+ * 1. Check if `@uwf/active-step/<threadId>` points to this step-start hash
519
+ * 2. If match, this step is in-flight (no step-complete written yet)
520
+ *
521
+ * Fails with the standard `thread not found` message for an unknown thread.
522
+ */
523
+ function buildTurnsPanorama(uwf, threadId) {
524
+ const entry = getThread(uwf.varStore, threadId);
525
+ if (entry === null) {
526
+ fail(`thread not found: ${threadId}`);
527
+ }
528
+ // Get the turn chain head and active-step (if any)
529
+ const turnHead = getActiveTurnHead(uwf.store, threadId);
530
+ const activeStepHash = getActiveStep(uwf.store, threadId);
531
+ // If no turns yet, try the legacy path via StepNode chain
532
+ if (turnHead === null) {
533
+ return buildTurnsPanoramaLegacy(uwf, threadId, entry.head);
534
+ }
535
+ // Walk the step-start chain from turn owners
536
+ const stepStarts = walkStepStartChain(uwf, turnHead);
537
+ const groups = [];
538
+ for (const stepStartHash of stepStarts) {
539
+ const node = uwf.store.cas.get(stepStartHash);
540
+ if (node === null || node.type !== uwf.schemas.stepStart)
541
+ continue;
542
+ const payload = node.payload;
543
+ const role = payload.role;
544
+ // Detect in-flight: active-step points to this step-start
545
+ const isInFlight = activeStepHash === stepStartHash;
546
+ // Get turns for this step using owner-based filtering
547
+ const turnHashes = turnsOfStep(uwf, turnHead, stepStartHash);
548
+ const turns = loadTurnData(uwf.store.cas, turnHashes);
549
+ groups.push({
550
+ role,
551
+ running: isInFlight,
552
+ turns,
553
+ stepStartHash,
419
554
  });
420
- return { stdout };
421
555
  }
422
- catch (e) {
423
- const err = e;
424
- if (err.code === "ENOENT") {
425
- fail(`"${agent.command}" not found in PATH. Install it or check your PATH config. Run: which ${agent.command}`);
556
+ return groups;
557
+ }
558
+ /**
559
+ * Legacy fallback for threads without new turn chain structure.
560
+ * Uses the old role-keyed active vars and StepNode detail.turns.
561
+ */
562
+ function buildTurnsPanoramaLegacy(uwf, threadId, headHash) {
563
+ const chain = walkChain(uwf, headHash);
564
+ const ordered = collectOrderedSteps(uwf, headHash, chain);
565
+ const activeRoles = readActiveTurnRoles(uwf.store, threadId);
566
+ const activeByRole = new Map(activeRoles.map((a) => [a.role, a.turns]));
567
+ const consumed = new Set();
568
+ const groups = [];
569
+ for (const item of ordered) {
570
+ const role = item.payload.role;
571
+ const active = activeByRole.get(role);
572
+ if (active !== undefined && active.length > 0 && !consumed.has(role)) {
573
+ groups.push({
574
+ role,
575
+ running: true,
576
+ turns: loadTurnData(uwf.store.cas, active),
577
+ stepStartHash: null,
578
+ });
579
+ consumed.add(role);
580
+ }
581
+ else {
582
+ groups.push({
583
+ role,
584
+ running: false,
585
+ turns: loadTurnData(uwf.store.cas, readStepDetailTurns(uwf, item.hash)),
586
+ stepStartHash: null,
587
+ });
588
+ }
589
+ }
590
+ for (const { role, turns } of activeRoles) {
591
+ if (consumed.has(role)) {
592
+ continue;
426
593
  }
427
- const stderr = err.stderr == null
428
- ? ""
429
- : typeof err.stderr === "string"
430
- ? err.stderr
431
- : err.stderr.toString("utf8");
432
- const detail = stderr.trim() !== "" ? `: ${stderr.trim()}` : "";
433
- fail(`agent command failed (${agent.command})${detail}`);
594
+ groups.push({
595
+ role,
596
+ running: true,
597
+ turns: loadTurnData(uwf.store.cas, turns),
598
+ stepStartHash: null,
599
+ });
600
+ consumed.add(role);
434
601
  }
602
+ return groups;
435
603
  }
436
- function resolveAskWorkflow(uwf, payload) {
437
- const startNode = uwf.store.cas.get(payload.start);
438
- if (startNode === null) {
439
- return null;
604
+ /**
605
+ * Filter the panorama to a single role (exact-match), or pass it through
606
+ * unchanged when `role === null` (show every role's steps). `--role` is a filter
607
+ * over the whole-chain panorama, so it keeps **all** steps of that role across
608
+ * the thread (e.g. a role that ran in two rounds), not just the latest.
609
+ */
610
+ function filterPanoramaByRole(groups, role) {
611
+ if (role === null) {
612
+ return groups;
440
613
  }
441
- const start = startNode.payload;
442
- const workflowNode = uwf.store.cas.get(start.workflow);
443
- if (workflowNode === null) {
444
- return null;
614
+ return groups.filter((g) => g.role === role);
615
+ }
616
+ /** Render a single turn's `## Turn N` block (1-based) via the reused pipeline. */
617
+ function formatTurnBlock(turn, displayIndex) {
618
+ return `## Turn ${displayIndex}\n\n${formatTurnBody(turn)}`;
619
+ }
620
+ /**
621
+ * Slice the panorama's flattened cross-step turn sequence to `[offset, offset+limit)`
622
+ * (`limit === null` → no upper bound, the OCAS `ListOptions` "no limit" convention),
623
+ * keeping each surviving turn's **global** index so numbering is consistent across
624
+ * the whole panorama. Returns per-group survivors paired with their group, so
625
+ * grouping/markers are preserved while pagination removes turns (not steps).
626
+ */
627
+ function paginatePanorama(groups, offset, limit) {
628
+ const start = offset > 0 ? offset : 0;
629
+ const end = limit === null ? Number.POSITIVE_INFINITY : start + Math.max(0, limit);
630
+ let globalIndex = 0;
631
+ const result = [];
632
+ for (const group of groups) {
633
+ const survivors = [];
634
+ for (const turn of group.turns) {
635
+ const idx = globalIndex;
636
+ globalIndex += 1;
637
+ if (idx >= start && idx < end) {
638
+ survivors.push({ turn, globalIndex: idx });
639
+ }
640
+ }
641
+ result.push({ group, turns: survivors });
642
+ }
643
+ return result;
644
+ }
645
+ /** Step group header, e.g. `## developer ✓ (47 turns)` / `## reviewer 🔄 进行中 (12 turns so far)`. */
646
+ function formatGroupHeader(group) {
647
+ const count = group.turns.length;
648
+ if (group.running) {
649
+ return `## ${group.role} 🔄 进行中 (${count} turns so far)`;
445
650
  }
446
- return workflowNode.payload;
651
+ return `## ${group.role} ✓ (${count} turns)`;
447
652
  }
448
- async function performFork(agent, agentName, stepHash, sourceSessionId, storageRoot, cwd) {
449
- const cached = await getAskSessionId(agentName, stepHash, storageRoot);
450
- if (cached !== null) {
451
- return cached;
653
+ /**
654
+ * Assemble the whole-thread turn panorama markdown (#409): a thread header, then
655
+ * one group per step (role + `✓`/`🔄 进行中` marker + turn count), and under each
656
+ * the surviving turns rendered via the reused `formatTurnBlock` pipeline with
657
+ * their global (cross-step) turn numbers. A group whose turns are entirely sliced
658
+ * out by pagination still shows its header (zero turns beneath).
659
+ */
660
+ function formatPanoramaMarkdown(threadId, groups, offset, limit) {
661
+ const parts = [`# Thread ${threadId}`];
662
+ const paged = paginatePanorama(groups, offset, limit);
663
+ for (const { group, turns } of paged) {
664
+ parts.push("");
665
+ parts.push(formatGroupHeader(group));
666
+ for (const { turn, globalIndex } of turns) {
667
+ parts.push("");
668
+ parts.push(formatTurnBlock(turn, globalIndex + 1));
669
+ }
452
670
  }
453
- const { stdout } = spawnAskAgent(agent, ["--mode", "fork", "--session", sourceSessionId], cwd);
454
- const newSessionId = stdout.trim().split("\n").pop()?.trim() ?? "";
455
- if (newSessionId === "") {
456
- fail(`agent fork did not return a session id (${agent.command})`);
671
+ return parts.join("\n");
672
+ }
673
+ /**
674
+ * Resolve the turn hashes to flush when the followed step finishes (active var
675
+ * gone AND thread no longer running). Phase 3: uses active-turn-head and owner
676
+ * filtering via turnsOfStep. Falls back to legacy role-keyed vars if no turn
677
+ * chain exists.
678
+ */
679
+ function resolveFinalTurnHashesPhase3(uwf, threadId, activeStepStart) {
680
+ const turnHead = getActiveTurnHead(uwf.store, threadId);
681
+ if (turnHead !== null && activeStepStart !== null) {
682
+ return turnsOfStep(uwf, turnHead, activeStepStart);
457
683
  }
458
- await setAskSessionId(agentName, stepHash, newSessionId, storageRoot);
459
- return newSessionId;
684
+ // Fallback: no new turn chain, return empty
685
+ return [];
460
686
  }
461
687
  /**
462
- * Ask a follow-up question to a historical step's agent (read-only).
688
+ * Legacy fallback for resolveFinalTurnHashes when thread uses role-keyed vars.
689
+ */
690
+ function resolveFinalTurnHashesLegacy(uwf, threadId, followRole) {
691
+ const remaining = readActiveTurns(uwf.store, threadId, followRole);
692
+ if (remaining.length > 0) {
693
+ return remaining;
694
+ }
695
+ const entry = getThread(uwf.varStore, threadId);
696
+ if (entry === null) {
697
+ return [];
698
+ }
699
+ return readRoleDetailTurnsFromChain(uwf, entry.head, followRole);
700
+ }
701
+ /**
702
+ * Get turns for the in-flight step using Phase 3 owner-based filtering.
703
+ * Returns turn hashes owned by the active step-start.
704
+ */
705
+ function getInFlightTurns(uwf, threadId) {
706
+ const turnHead = getActiveTurnHead(uwf.store, threadId);
707
+ const activeStepStart = getActiveStep(uwf.store, threadId);
708
+ if (turnHead === null || activeStepStart === null) {
709
+ return [];
710
+ }
711
+ return turnsOfStep(uwf, turnHead, activeStepStart);
712
+ }
713
+ /**
714
+ * Check if thread uses Phase 3 turn chain (has active-turn-head var).
715
+ */
716
+ function hasPhase3TurnChain(uwf, threadId) {
717
+ return (getActiveTurnHead(uwf.store, threadId) !== null || getActiveStep(uwf.store, threadId) !== null);
718
+ }
719
+ /** Get active turns based on Phase 3 vs legacy mode. */
720
+ function getActiveTurnsForLive(uwf, threadId, state, followRole) {
721
+ if (state.usePhase3) {
722
+ const activeStepStart = getActiveStep(uwf.store, threadId);
723
+ if (activeStepStart !== null) {
724
+ state.lastActiveStepStart = activeStepStart;
725
+ }
726
+ return getInFlightTurns(uwf, threadId);
727
+ }
728
+ return readActiveTurns(uwf.store, threadId, followRole);
729
+ }
730
+ /** Get final turns for reconciliation based on Phase 3 vs legacy mode. */
731
+ function getFinalTurnsForLive(uwf, threadId, state, followRole) {
732
+ if (state.usePhase3) {
733
+ return resolveFinalTurnHashesPhase3(uwf, threadId, state.lastActiveStepStart);
734
+ }
735
+ return resolveFinalTurnHashesLegacy(uwf, threadId, followRole);
736
+ }
737
+ /**
738
+ * `--live` follower: poll the in-flight step's turns via the Phase 3 turn chain,
739
+ * printing each new turn block exactly once (tracking how many blocks were emitted
740
+ * and rendering only the new tail).
463
741
  *
464
- * Does NOT write a new StepNode and does NOT mutate thread state. The agent's
465
- * raw stdout is returned so the CLI entry point can stream it directly.
742
+ * Phase 3 changes (#421):
743
+ * - Uses `getActiveTurnHead` and `getActiveStep` instead of role-keyed vars
744
+ * - Filters turns via `turnsOfStep(turnHead, activeStepStart)`
745
+ * - Exits when the thread is no longer running
746
+ *
747
+ * Backward compatible: Falls back to legacy role-keyed vars for threads without
748
+ * Phase 3 turn chain.
466
749
  */
467
- export async function cmdStepAsk(storageRoot, stepHash, options) {
750
+ async function followStepTurnsLive(storageRoot, threadId, opts) {
751
+ const emit = opts.onChunk ?? ((chunk) => process.stdout.write(chunk));
752
+ const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
753
+ const isRunning = opts.isRunning ?? (async () => (await isThreadRunning(storageRoot, threadId)) !== null);
754
+ const intervalMs = opts.pollIntervalMs ?? STEP_TURNS_POLL_INTERVAL_MS;
755
+ const followRole = opts.role ?? (await resolveLiveFollowRole(storageRoot, threadId));
756
+ const state = {
757
+ printedCount: 0,
758
+ lastActiveStepStart: null,
759
+ usePhase3: null,
760
+ };
761
+ const flush = (uwf, hashes) => {
762
+ if (hashes.length <= state.printedCount) {
763
+ return;
764
+ }
765
+ const tail = loadTurnData(uwf.store.cas, hashes.slice(state.printedCount));
766
+ for (let i = 0; i < tail.length; i++) {
767
+ const turn = tail[i];
768
+ if (turn === undefined)
769
+ continue;
770
+ emit(`${formatTurnBlock(turn, state.printedCount + i + 1)}\n`);
771
+ }
772
+ state.printedCount = hashes.length;
773
+ };
774
+ while (true) {
775
+ const uwf = await createUwfStore(storageRoot);
776
+ if (state.usePhase3 === null) {
777
+ state.usePhase3 = hasPhase3TurnChain(uwf, threadId);
778
+ }
779
+ const active = getActiveTurnsForLive(uwf, threadId, state, followRole);
780
+ flush(uwf, active);
781
+ const running = await isRunning();
782
+ if (!running) {
783
+ flush(uwf, getFinalTurnsForLive(uwf, threadId, state, followRole));
784
+ return;
785
+ }
786
+ await sleep(intervalMs);
787
+ }
788
+ }
789
+ /**
790
+ * Resolve the role for `--live` to follow when `--role` is omitted: the thread's
791
+ * current in-flight role. Prefers a role with a live `@uwf/active-turns` var
792
+ * (the genuinely in-flight step); falls back to the head StepNode's role, then to
793
+ * `"agent"` for a StartNode head. Fails with the standard `thread not found`
794
+ * message for an unknown thread.
795
+ */
796
+ async function resolveLiveFollowRole(storageRoot, threadId) {
468
797
  const uwf = await createUwfStore(storageRoot);
469
- const node = uwf.store.cas.get(stepHash);
470
- if (node === null) {
471
- fail(`CAS node not found: ${stepHash}`);
798
+ const entry = getThread(uwf.varStore, threadId);
799
+ if (entry === null) {
800
+ fail(`thread not found: ${threadId}`);
472
801
  }
473
- if (node.type !== uwf.schemas.stepNode) {
474
- fail(`node ${stepHash} is not a StepNode`);
802
+ const activeRoles = readActiveTurnRoles(uwf.store, threadId);
803
+ const lastActive = activeRoles[activeRoles.length - 1];
804
+ if (lastActive !== undefined) {
805
+ return lastActive.role;
475
806
  }
476
- const payload = node.payload;
477
- if (payload.detail === null) {
478
- fail(`step ${stepHash} has no detail; cannot ask`);
479
- }
480
- const detailRef = payload.detail;
481
- const { sessionId: sourceSessionId } = loadDetailNode(uwf.store.cas, detailRef);
482
- const workflow = resolveAskWorkflow(uwf, payload);
483
- const config = await loadWorkflowConfig(storageRoot);
484
- const agent = resolveAskAgentConfig(config, workflow, payload.role, options.agentOverride, payload.agent);
485
- const agentName = deriveAgentName(agent.command);
486
- const cwd = payload.cwd !== "" ? payload.cwd : process.cwd();
487
- // Fork path: fork (or reuse cached fork) ask with that session.
488
- if (options.fork && sourceSessionId !== null) {
489
- const askSessionId = await performFork(agent, agentName, stepHash, sourceSessionId, storageRoot, cwd);
490
- const argv = ["--mode", "ask", "--session", askSessionId, "--prompt", options.prompt];
491
- argv.push("--detail", detailRef);
492
- const { stdout } = spawnAskAgent(agent, argv, cwd);
493
- return stdout;
494
- }
495
- // Fallback path: ask without forking; inject detail ref for context.
496
- const argv = ["--mode", "ask", "--prompt", options.prompt];
497
- argv.push("--detail", detailRef);
498
- const { stdout } = spawnAskAgent(agent, argv, cwd);
499
- return stdout;
807
+ const node = uwf.store.cas.get(entry.head);
808
+ if (node !== null && node.type === uwf.schemas.stepNode) {
809
+ return node.payload.role;
810
+ }
811
+ return "agent";
812
+ }
813
+ /**
814
+ * `uwf step turns <thread-id> [--role <r>] [--live] [--limit <n>] [--offset <m>]`
815
+ * render the whole-thread turn panorama (#409): walk the entire chain and show
816
+ * every step's turns (each completed step from its immutable `detail.turns`, the
817
+ * in-flight step from its active var, marked `🔄 进行中`), through the same
818
+ * per-turn pipeline as `step read`. `--role` filters the panorama to one role;
819
+ * `--limit`/`--offset` paginate the flattened cross-step turn sequence (after the
820
+ * role filter). With `--live`, follow the in-flight step's active var, printing
821
+ * new turns incrementally.
822
+ *
823
+ * Returns the assembled markdown (non-live); for `--live` the output is streamed
824
+ * to `onChunk`/stdout and the resolved string is returned empty.
825
+ */
826
+ export async function cmdStepTurns(storageRoot, threadId, options) {
827
+ const opts = resolveStepTurnsOptions(storageRoot, threadId, options);
828
+ if (opts.live) {
829
+ await followStepTurnsLive(storageRoot, threadId, opts);
830
+ return "";
831
+ }
832
+ const uwf = await createUwfStore(storageRoot);
833
+ const panorama = buildTurnsPanorama(uwf, threadId);
834
+ const filtered = filterPanoramaByRole(panorama, opts.role);
835
+ return formatPanoramaMarkdown(threadId, filtered, opts.offset, opts.limit);
836
+ }
837
+ /**
838
+ * `uwf step ask` is unavailable in 0.x while broker integration (#381) is in
839
+ * progress. The legacy spawn-agent code path was removed alongside the
840
+ * `agents.<alias>: {command, args}` config shape. Use `uwf thread exec` /
841
+ * `uwf thread resume` instead — those routes go through `broker.send()` and
842
+ * preserve the Sumeru session.
843
+ */
844
+ export async function cmdStepAsk(_storageRoot, _stepHash, _options) {
845
+ fail("step ask is unavailable in 0.x while broker integration (#381) is in progress. " +
846
+ "The pre-broker spawn-agent path was removed in #380; equivalent ask/fork primitives " +
847
+ "will return in Phase 4 once the Sumeru broker exposes session-fork APIs. " +
848
+ "Use `uwf thread resume <id> -p '...'` to continue a suspended thread, or " +
849
+ "`uwf thread exec <id>` to advance an idle thread.");
500
850
  }
501
851
  //# sourceMappingURL=step.js.map