@fungi.computer/shiitake-extensions 0.2.0 → 0.4.0

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 (46) hide show
  1. package/README.md +33 -21
  2. package/dist/goal/contract.d.ts +27 -8
  3. package/dist/goal/contract.js +20 -3
  4. package/dist/goal/host.d.ts +4 -1
  5. package/dist/goal/host.js +375 -128
  6. package/dist/goal/prompts.d.ts +73 -0
  7. package/dist/goal/prompts.js +98 -0
  8. package/dist/home/host.js +9 -3
  9. package/dist/internal/headline.d.ts +7 -0
  10. package/dist/internal/headline.js +18 -0
  11. package/dist/loop/client.js +1 -1
  12. package/dist/loop/contract.d.ts +6 -2
  13. package/dist/loop/contract.js +10 -1
  14. package/dist/loop/host.js +10 -12
  15. package/dist/loop/prompts.d.ts +56 -0
  16. package/dist/loop/prompts.js +54 -0
  17. package/dist/question/answer-flow.d.ts +27 -0
  18. package/dist/question/answer-flow.js +115 -0
  19. package/dist/question/client.d.ts +1 -1
  20. package/dist/question/client.js +22 -3
  21. package/dist/question/contract.d.ts +202 -60
  22. package/dist/question/contract.js +78 -22
  23. package/dist/question/host.d.ts +4 -0
  24. package/dist/question/host.js +348 -304
  25. package/dist/roster/conversation-agents.d.ts +22 -15
  26. package/dist/roster/conversation-agents.js +161 -37
  27. package/dist/roster.d.ts +8 -2
  28. package/dist/roster.js +33 -6
  29. package/dist/skills/host.js +20 -2
  30. package/dist/subagents/client.js +64 -20
  31. package/dist/subagents/contract.d.ts +123 -10
  32. package/dist/subagents/contract.js +38 -5
  33. package/dist/subagents/host.d.ts +5 -5
  34. package/dist/subagents/host.js +152 -22
  35. package/docs/api/goal/contract.md +5 -2
  36. package/docs/api/goal/host.md +22 -1
  37. package/docs/api/home/host.md +6 -5
  38. package/docs/api/loop/contract.md +5 -2
  39. package/docs/api/question/client.md +2 -1
  40. package/docs/api/question/contract.md +183 -51
  41. package/docs/api/question/host.md +30 -0
  42. package/docs/api/roster/conversation-agents.md +15 -7
  43. package/docs/api/roster.md +39 -2
  44. package/docs/api/subagents/contract.md +101 -28
  45. package/docs/api/subagents/host.md +5 -2
  46. package/package.json +5 -6
@@ -0,0 +1,73 @@
1
+ import type { Goal } from "./contract.js";
2
+ /** The `/goal` menu for a live or paused goal. */
3
+ export declare function goalMenuPrompt(goal: Goal, now: number): {
4
+ type: "choice";
5
+ message: string;
6
+ summary: string;
7
+ facts: {
8
+ label: string;
9
+ value: string;
10
+ }[];
11
+ details: {
12
+ label: string;
13
+ value: string;
14
+ }[];
15
+ options: ({
16
+ value: string;
17
+ label: string;
18
+ destructive?: never;
19
+ } | {
20
+ value: string;
21
+ label: string;
22
+ destructive: true;
23
+ })[];
24
+ } | {
25
+ body: string;
26
+ bodyLabel: string;
27
+ type: "choice";
28
+ message: string;
29
+ summary: string;
30
+ facts: {
31
+ label: string;
32
+ value: string;
33
+ }[];
34
+ details: {
35
+ label: string;
36
+ value: string;
37
+ }[];
38
+ options: ({
39
+ value: string;
40
+ label: string;
41
+ destructive?: never;
42
+ } | {
43
+ value: string;
44
+ label: string;
45
+ destructive: true;
46
+ })[];
47
+ };
48
+ /** The destructive step after choosing Clear: Keep is first and focused. */
49
+ export declare const clearConfirmation: {
50
+ type: "confirmation";
51
+ message: string;
52
+ summary: string;
53
+ confirmLabel: string;
54
+ cancelLabel: string;
55
+ destructive: true;
56
+ };
57
+ /** The destructive step after typing a replacement goal. */
58
+ export declare function replaceConfirmation(current: Goal, replacement: string): {
59
+ type: "confirmation";
60
+ message: string;
61
+ summary: string;
62
+ facts: {
63
+ label: string;
64
+ value: string;
65
+ }[];
66
+ details: {
67
+ label: string;
68
+ value: string;
69
+ }[];
70
+ confirmLabel: string;
71
+ cancelLabel: string;
72
+ destructive: true;
73
+ };
@@ -0,0 +1,98 @@
1
+ import { headline, titleLimit } from "../internal/headline.js";
2
+ const pad = (value) => String(value).padStart(2, "0");
3
+ /** A start time a person reads: 14:02 today, yesterday 14:02, or a full date. */
4
+ function startedAt(epochMillis, now) {
5
+ const at = new Date(epochMillis);
6
+ const clock = `${pad(at.getHours())}:${pad(at.getMinutes())}`;
7
+ const day = (date) => new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
8
+ const days = Math.round((day(new Date(now)) - day(at)) / 86_400_000);
9
+ if (days === 0)
10
+ return `${clock} today`;
11
+ if (days === 1)
12
+ return `${clock} yesterday`;
13
+ return `${String(at.getFullYear())}-${pad(at.getMonth() + 1)}-${pad(at.getDate())} ${clock}`;
14
+ }
15
+ /** What happened, by the typed cause and never by the goal's own words. */
16
+ const pausedSummary = {
17
+ person: "You paused this goal.",
18
+ blocked: "The Agent is stuck and needs you.",
19
+ stalled: "No progress for 3 turns, so Fungi paused it.",
20
+ cancelled: "You stopped the goal's turn.",
21
+ failed: "The last turn failed. Resume to try again.",
22
+ "no-terminal": "The goal's last turn ended without a final reply.",
23
+ };
24
+ const activeSummary = "Fungi keeps working until it is done or you pause it.";
25
+ /** The `/goal` menu for a live or paused goal. */
26
+ export function goalMenuPrompt(goal, now) {
27
+ const active = goal.state === "active";
28
+ const lead = active ? "Working on: " : "Goal paused: ";
29
+ const name = headline(goal.objective, titleLimit - lead.length);
30
+ const cause = goal.pauseCause;
31
+ const full = goal.objective.slice(0, 4_000);
32
+ const menu = {
33
+ type: "choice",
34
+ message: `${lead}${name}`,
35
+ summary: active ? activeSummary : pausedSummary[cause?.kind ?? "person"],
36
+ facts: [
37
+ { label: "Started", value: startedAt(goal.startedAt, now) },
38
+ ...(!active && cause?.kind === "blocked" && cause.detail != null
39
+ ? [{ label: "Why", value: `“${cause.detail.slice(0, 500)}”` }]
40
+ : []),
41
+ ],
42
+ details: [
43
+ { label: "Goal", value: full },
44
+ ...(cause === null
45
+ ? []
46
+ : [
47
+ {
48
+ label: "Paused",
49
+ value: cause.detail == null
50
+ ? cause.kind
51
+ : `${cause.kind} · ${cause.detail.slice(0, 2_000)}`,
52
+ },
53
+ ]),
54
+ { label: "Started", value: new Date(goal.startedAt).toLocaleString() },
55
+ ],
56
+ options: [
57
+ active
58
+ ? { value: "pause", label: "Pause goal" }
59
+ : { value: "resume", label: "Resume goal" },
60
+ { value: "replace", label: "Replace goal…" },
61
+ { value: "clear-ask", label: "Clear goal…", destructive: true },
62
+ ],
63
+ };
64
+ // A one-sentence goal is already the title; only longer text gets a body.
65
+ return full.replace(/\s+/gu, " ").trim() === name
66
+ ? menu
67
+ : { ...menu, body: full, bodyLabel: "Goal" };
68
+ }
69
+ /** The destructive step after choosing Clear: Keep is first and focused. */
70
+ export const clearConfirmation = {
71
+ type: "confirmation",
72
+ message: "Clear this goal?",
73
+ summary: "It moves to history and can't be resumed.",
74
+ confirmLabel: "Clear goal",
75
+ cancelLabel: "Keep goal",
76
+ destructive: true,
77
+ };
78
+ /** The destructive step after typing a replacement goal. */
79
+ export function replaceConfirmation(current, replacement) {
80
+ const old = current.objective.slice(0, 4_000);
81
+ const next = replacement.slice(0, 4_000);
82
+ return {
83
+ type: "confirmation",
84
+ message: `Replace the ${current.state === "active" ? "live" : "paused"} goal?`,
85
+ summary: "The current goal moves to history and can't be resumed.",
86
+ facts: [
87
+ { label: "Current", value: headline(old, 120) },
88
+ { label: "New", value: headline(next, 120) },
89
+ ],
90
+ details: [
91
+ { label: "Current", value: old },
92
+ { label: "New", value: next },
93
+ ],
94
+ confirmLabel: "Replace and start",
95
+ cancelLabel: "Keep current goal",
96
+ destructive: true,
97
+ };
98
+ }
package/dist/home/host.js CHANGED
@@ -100,14 +100,20 @@ export function homeExtension(options) {
100
100
  type: "info",
101
101
  message: "No Computers attached. Attach one before choosing Home.",
102
102
  });
103
- return ask({
103
+ const recorded = Option.getOrNull(yield* ctx.state.choice.get("default"));
104
+ const home = computers.find(({ attachmentId }) => attachmentId === recorded?.attachmentId);
105
+ const prompt = {
104
106
  type: "choice",
105
- message: "Choose this Agent's Home Computer",
107
+ message: "Choose a Home Computer",
108
+ summary: "The Agent reads and edits files here by default.",
106
109
  options: computers.map(({ name }) => ({
107
110
  value: name,
108
111
  label: name,
109
112
  })),
110
- }, {}, "name");
113
+ };
114
+ return ask(home === undefined
115
+ ? prompt
116
+ : { ...prompt, current: home.name }, {}, "name");
111
117
  }
112
118
  const selected = computers.find(({ name }) => name === input.name);
113
119
  if (selected === undefined)
@@ -0,0 +1,7 @@
1
+ /** Longest title a modal carries. */
2
+ export declare const titleLimit = 60;
3
+ /**
4
+ * A short name for long user text: its first line's first sentence, whitespace collapsed
5
+ * and cut at a word to `limit` characters with an ellipsis.
6
+ */
7
+ export declare function headline(objective: string, limit: number): string;
@@ -0,0 +1,18 @@
1
+ /** Longest title a modal carries. */
2
+ export const titleLimit = 60;
3
+ /**
4
+ * A short name for long user text: its first line's first sentence, whitespace collapsed
5
+ * and cut at a word to `limit` characters with an ellipsis.
6
+ */
7
+ export function headline(objective, limit) {
8
+ const line = objective
9
+ .split("\n")
10
+ .map((part) => part.replace(/\s+/gu, " ").trim())
11
+ .find((part) => part !== "") ?? "";
12
+ const sentence = (/^.*?[.!?](?=\s|$)/u.exec(line)?.[0] ?? line).replace(/[.!?]+$/u, "");
13
+ if (sentence.length <= limit)
14
+ return sentence;
15
+ const cut = sentence.slice(0, Math.max(0, limit - 1));
16
+ const space = cut.lastIndexOf(" ");
17
+ return `${(space > limit / 2 ? cut.slice(0, space) : cut).trimEnd()}…`;
18
+ }
@@ -74,7 +74,7 @@ export default defineClientEntry(loopContract, {
74
74
  cancel: {
75
75
  id: "loop",
76
76
  label: "stop loop",
77
- input: { action: "stop" },
77
+ input: { action: "stop-ask" },
78
78
  },
79
79
  }
80
80
  : null,
@@ -54,7 +54,9 @@ export declare const LoopCommandInput: Schema.Union<readonly [Schema.Struct<{
54
54
  readonly requestId: Schema.String;
55
55
  }>, Schema.Struct<{
56
56
  /** Select an existing-loop control or open new-loop entry. */
57
- readonly action: Schema.Literals<readonly ["status", "pause", "resume", "stop", "new"]>;
57
+ readonly action: Schema.Literals<readonly ["status", "pause", "resume", "stop", "stop-ask", "new"]>;
58
+ /** The human's answer to "stop this loop?"; absent for a typed stop. */
59
+ readonly confirmed: Schema.optional<Schema.Boolean>;
58
60
  }>, Schema.Struct<{}>]>;
59
61
  /** Loop's contract: Session-scope controls and one projection slot. */
60
62
  export declare const LoopProjection: Schema.Struct<{
@@ -111,7 +113,9 @@ export declare const loopContract: {
111
113
  readonly requestId: Schema.String;
112
114
  }>, Schema.Struct<{
113
115
  /** Select an existing-loop control or open new-loop entry. */
114
- readonly action: Schema.Literals<readonly ["status", "pause", "resume", "stop", "new"]>;
116
+ readonly action: Schema.Literals<readonly ["status", "pause", "resume", "stop", "stop-ask", "new"]>;
117
+ /** The human's answer to "stop this loop?"; absent for a typed stop. */
118
+ readonly confirmed: Schema.optional<Schema.Boolean>;
115
119
  }>, Schema.Struct<{}>]>;
116
120
  /** Return the server-owned conversation step. */
117
121
  readonly output: Schema.declare<import("@fungi.computer/whistle/catalog").WhistleCatalogStepV1, import("@fungi.computer/whistle/catalog").WhistleCatalogStepV1>;
@@ -75,7 +75,16 @@ export const LoopCommandInput = Schema.Union([
75
75
  }),
76
76
  Schema.Struct({
77
77
  /** Select an existing-loop control or open new-loop entry. */
78
- action: Schema.Literals(["status", "pause", "resume", "stop", "new"]),
78
+ action: Schema.Literals([
79
+ "status",
80
+ "pause",
81
+ "resume",
82
+ "stop",
83
+ "stop-ask",
84
+ "new",
85
+ ]),
86
+ /** The human's answer to "stop this loop?"; absent for a typed stop. */
87
+ confirmed: Schema.optional(Schema.Boolean),
79
88
  }),
80
89
  Schema.Struct({}),
81
90
  ]);
package/dist/loop/host.js CHANGED
@@ -8,9 +8,10 @@ import * as Exit from "effect/Exit";
8
8
  import * as Option from "effect/Option";
9
9
  import * as Schema from "effect/Schema";
10
10
  import { z } from "zod";
11
- import { ask, catalog, done, freshRequestId } from "../internal/catalog.js";
11
+ import { ask, catalog, declined, done, freshRequestId, } from "../internal/catalog.js";
12
12
  import { nextOccurrence } from "../internal/cron.js";
13
13
  import { Loop, LoopCommandInput, loopContract } from "./contract.js";
14
+ import { loopMenuPrompt, stopConfirmation } from "./prompts.js";
14
15
  /** The rules the model follows; the describe page and a live loop's prompt share them. */
15
16
  const rules = "You may start loops yourself; one live loop per Session. Failed loop ticks retry with backoff; loop.get shows consecutiveFailures, lastError, and nextDueAt. schedule_next is allowed only in the active self-paced loop's own run, clamped to 60–3600 seconds. Omitting it ends that loop. stop works in any Session run. Human pause/resume/stop and Esc remain user controls.";
16
17
  /** Hashed prose and policy: every constant loop reads comes from here. */
@@ -521,7 +522,8 @@ function loopLine(loop) {
521
522
  }
522
523
  const loopPrompt = {
523
524
  type: "text",
524
- message: "What should the loop repeat? Start with 5m or 1h for a fixed interval.",
525
+ message: "What should the loop repeat?",
526
+ summary: "Start with 5m or 1h for a fixed interval.",
525
527
  placeholder: "Check the build and fix what broke",
526
528
  };
527
529
  /** The human `/loop` conversation, decided from the committed loop. */
@@ -533,16 +535,7 @@ function humanLoop(ctx, sessionId, input) {
533
535
  if (!("action" in input)) {
534
536
  if (current === undefined || !live)
535
537
  return ask(loopPrompt, { action: "start", requestId: yield* freshRequestId }, "text");
536
- return ask({
537
- type: "choice",
538
- message: loopLine(current),
539
- options: [
540
- current.state === "active"
541
- ? { value: "pause", label: "Pause loop" }
542
- : { value: "resume", label: "Resume loop" },
543
- { value: "stop", label: "Stop loop" },
544
- ],
545
- }, {}, "action");
538
+ return ask(loopMenuPrompt(current), {}, "action");
546
539
  }
547
540
  switch (input.action) {
548
541
  case "status":
@@ -564,7 +557,12 @@ function humanLoop(ctx, sessionId, input) {
564
557
  type: "success",
565
558
  message: loopLine(yield* resumeLoop(ctx, sessionId)),
566
559
  });
560
+ case "stop-ask":
561
+ // The menu's Stop: Keep is the focused answer to this destructive step.
562
+ return ask(stopConfirmation, { action: "stop" }, "confirmed");
567
563
  case "stop":
564
+ if (input.confirmed === false)
565
+ return declined({ type: "info", message: "Kept the loop." });
568
566
  return done({
569
567
  type: "success",
570
568
  message: loopLine(yield* stopLoop(ctx, sessionId)),
@@ -0,0 +1,56 @@
1
+ import type { Loop } from "./contract.js";
2
+ /** The `/loop` menu for a live or paused loop. */
3
+ export declare function loopMenuPrompt(loop: Loop): {
4
+ type: "choice";
5
+ message: string;
6
+ summary: string;
7
+ facts: {
8
+ label: string;
9
+ value: string;
10
+ }[];
11
+ details: {
12
+ label: string;
13
+ value: string;
14
+ }[];
15
+ options: ({
16
+ value: string;
17
+ label: string;
18
+ destructive?: never;
19
+ } | {
20
+ value: string;
21
+ label: string;
22
+ destructive: true;
23
+ })[];
24
+ } | {
25
+ body: string;
26
+ bodyLabel: string;
27
+ type: "choice";
28
+ message: string;
29
+ summary: string;
30
+ facts: {
31
+ label: string;
32
+ value: string;
33
+ }[];
34
+ details: {
35
+ label: string;
36
+ value: string;
37
+ }[];
38
+ options: ({
39
+ value: string;
40
+ label: string;
41
+ destructive?: never;
42
+ } | {
43
+ value: string;
44
+ label: string;
45
+ destructive: true;
46
+ })[];
47
+ };
48
+ /** The destructive step after choosing Stop: Keep is first and focused. */
49
+ export declare const stopConfirmation: {
50
+ type: "confirmation";
51
+ message: string;
52
+ summary: string;
53
+ confirmLabel: string;
54
+ cancelLabel: string;
55
+ destructive: true;
56
+ };
@@ -0,0 +1,54 @@
1
+ import { headline, titleLimit } from "../internal/headline.js";
2
+ /** The `/loop` menu for a live or paused loop. */
3
+ export function loopMenuPrompt(loop) {
4
+ const active = loop.state === "active";
5
+ const lead = active ? "Loop running: " : "Loop paused: ";
6
+ const name = headline(loop.prompt, titleLimit - lead.length);
7
+ const full = loop.prompt.slice(0, 4_000);
8
+ const menu = {
9
+ type: "choice",
10
+ message: `${lead}${name}`,
11
+ summary: loop.mode === "self_paced"
12
+ ? "The Agent decides when to run it again."
13
+ : "It runs again on a fixed schedule.",
14
+ facts: [
15
+ {
16
+ label: "Mode",
17
+ value: loop.mode === "self_paced" ? "Self-paced" : "Fixed interval",
18
+ },
19
+ {
20
+ label: "Started",
21
+ value: loop.origin === "human" ? "By you" : "By the Agent",
22
+ },
23
+ ],
24
+ details: [
25
+ { label: "Prompt", value: full },
26
+ ...(loop.lastError === null
27
+ ? []
28
+ : [{ label: "Last error", value: loop.lastError.slice(0, 2_000) }]),
29
+ {
30
+ label: "Failures",
31
+ value: String(loop.consecutiveFailures),
32
+ },
33
+ ],
34
+ options: [
35
+ active
36
+ ? { value: "pause", label: "Pause loop" }
37
+ : { value: "resume", label: "Resume loop" },
38
+ { value: "stop-ask", label: "Stop loop…", destructive: true },
39
+ ],
40
+ };
41
+ // A short prompt is already the title; only longer text gets a body.
42
+ return full.replace(/\s+/gu, " ").trim() === name
43
+ ? menu
44
+ : { ...menu, body: full, bodyLabel: "Prompt" };
45
+ }
46
+ /** The destructive step after choosing Stop: Keep is first and focused. */
47
+ export const stopConfirmation = {
48
+ type: "confirmation",
49
+ message: "Stop this loop?",
50
+ summary: "It won't run again. You can start a new one.",
51
+ confirmLabel: "Stop loop",
52
+ cancelLabel: "Keep loop",
53
+ destructive: true,
54
+ };
@@ -0,0 +1,27 @@
1
+ import type { WhistleCatalogStepV1 } from "@fungi.computer/whistle/catalog";
2
+ import { ExtensionCommandError } from "@fungi.computer/shiitake/extensions";
3
+ import * as Effect from "effect/Effect";
4
+ import { type Answers, type AnswerFlowInput, type Question } from "./contract.js";
5
+ import type * as Schema from "effect/Schema";
6
+ /** Drafts travel in catalog arguments; only an owner's answer commits them. */
7
+ export type FlowInput = Schema.Schema.Type<typeof AnswerFlowInput>;
8
+ /** What the answer conversation does next for one open Ask. */
9
+ export type AnswerFlowOutcome = Readonly<{
10
+ type: "step";
11
+ step: WhistleCatalogStepV1;
12
+ }> | Readonly<{
13
+ type: "complete";
14
+ askId: string;
15
+ requestId: string;
16
+ answers: Answers;
17
+ }>;
18
+ /**
19
+ * Progress one Ask's answer draft by a single human step. The owner of the
20
+ * Ask commits a completed set; a step only ever returns the next prompt, so
21
+ * the same conversation serves the Ask's own Session and a parent's footer.
22
+ */
23
+ export declare function advanceAnswerFlow(row: Readonly<{
24
+ askId: string;
25
+ questions: readonly Question[];
26
+ closed: boolean;
27
+ }>, input: FlowInput): Effect.Effect<AnswerFlowOutcome, ExtensionCommandError>;
@@ -0,0 +1,115 @@
1
+ import { ExtensionCommandError } from "@fungi.computer/shiitake/extensions";
2
+ import * as Effect from "effect/Effect";
3
+ import { ask, done, freshRequestId } from "../internal/catalog.js";
4
+ import { titleLimit } from "../internal/headline.js";
5
+ import {} from "./contract.js";
6
+ /** Accept only picks the question offered, and a non-empty answer. */
7
+ function decisionAnswer(question, answer) {
8
+ const offered = new Set(question.options.map((option) => option.label));
9
+ const written = answer.text?.trim() ?? "";
10
+ if (answer.selected.some((label) => !offered.has(label)) ||
11
+ new Set(answer.selected).size !== answer.selected.length ||
12
+ (question.multiple !== true && answer.selected.length > 1) ||
13
+ (answer.selected.length === 0 && written === ""))
14
+ return Effect.fail(new ExtensionCommandError({
15
+ reason: "invalid",
16
+ message: "Choose offered options or write an answer",
17
+ }));
18
+ return Effect.succeed(written === ""
19
+ ? { selected: answer.selected }
20
+ : { selected: answer.selected, text: answer.text ?? "" });
21
+ }
22
+ /**
23
+ * The Agent's own words are the title only when they fit one; a longer
24
+ * question gets a plain title and sits in the body, whole in the details.
25
+ */
26
+ function questionHeading(question) {
27
+ const text = question.question.replace(/\s+/gu, " ").trim();
28
+ if (text.length <= titleLimit)
29
+ return { message: text };
30
+ return {
31
+ message: "The Agent has a question",
32
+ body: question.question.slice(0, 4_000),
33
+ bodyLabel: "Question",
34
+ details: [{ label: "Question", value: question.question.slice(0, 4_000) }],
35
+ };
36
+ }
37
+ function promptQuestion(nextQuestion, next, total) {
38
+ const base = {
39
+ type: "decision",
40
+ ...questionHeading(nextQuestion),
41
+ options: nextQuestion.options.map((option) => ({
42
+ value: option.label,
43
+ label: option.label,
44
+ description: option.description,
45
+ })),
46
+ other: {
47
+ label: "Other…",
48
+ placeholder: "Write your own answer",
49
+ maxLength: 4_000,
50
+ },
51
+ };
52
+ // Only present fields travel: the wire is JSON, which has no undefined.
53
+ const numbered = total > 1
54
+ ? {
55
+ ...base,
56
+ summary: `Question ${String(next.index + 1)} of ${String(total)}`,
57
+ }
58
+ : base;
59
+ return ask(nextQuestion.multiple === true ? { ...numbered, multiple: true } : numbered, { stage: "decision", ...next }, "answer");
60
+ }
61
+ /**
62
+ * Progress one Ask's answer draft by a single human step. The owner of the
63
+ * Ask commits a completed set; a step only ever returns the next prompt, so
64
+ * the same conversation serves the Ask's own Session and a parent's footer.
65
+ */
66
+ export function advanceAnswerFlow(row, input) {
67
+ return Effect.gen(function* () {
68
+ const draft = "stage" in input
69
+ ? input
70
+ : {
71
+ stage: "start",
72
+ index: 0,
73
+ requestId: yield* freshRequestId,
74
+ answers: {},
75
+ };
76
+ let { index, answers } = draft;
77
+ const { requestId } = draft;
78
+ const question = row.questions[index];
79
+ if (question === undefined)
80
+ return yield* Effect.fail(new ExtensionCommandError({
81
+ reason: "invalid",
82
+ message: "Unknown question step",
83
+ }));
84
+ const answer = draft.stage === "decision"
85
+ ? yield* decisionAnswer(question, draft.answer)
86
+ : undefined;
87
+ if (answer !== undefined) {
88
+ answers = { ...answers, [question.id]: answer };
89
+ index += 1;
90
+ if (index === row.questions.length)
91
+ return {
92
+ type: "complete",
93
+ askId: row.askId,
94
+ requestId,
95
+ answers,
96
+ };
97
+ }
98
+ if (row.closed)
99
+ return {
100
+ type: "step",
101
+ step: done({
102
+ type: "info",
103
+ message: "That question is already closed.",
104
+ }),
105
+ };
106
+ const next = { askId: row.askId, requestId, index, answers };
107
+ const nextQuestion = row.questions[index];
108
+ if (nextQuestion === undefined)
109
+ throw new Error("Expected the next question");
110
+ return {
111
+ type: "step",
112
+ step: promptQuestion(nextQuestion, next, row.questions.length),
113
+ };
114
+ });
115
+ }
@@ -1,5 +1,5 @@
1
1
  import { type ClientContributionV1 } from "@fungi.computer/shiitake/client-entry";
2
- /** Pending decisions use the existing footer rows and advertised answer flow. */
2
+ /** Pending decisions take over the composer; the footer row is where a postponed one waits. */
3
3
  declare const _default: Readonly<{
4
4
  protocol: "shiitake.client/v1";
5
5
  id: import("@fungi.computer/shiitake/extensions").ExtensionId;
@@ -1,6 +1,6 @@
1
1
  import { defineClientEntry, } from "@fungi.computer/shiitake/client-entry";
2
2
  import { questionContract } from "./contract.js";
3
- /** Pending decisions use the existing footer rows and advertised answer flow. */
3
+ /** Pending decisions take over the composer; the footer row is where a postponed one waits. */
4
4
  export default defineClientEntry(questionContract, {
5
5
  contribute: (view) => ({
6
6
  chips: [],
@@ -8,16 +8,35 @@ export default defineClientEntry(questionContract, {
8
8
  ? []
9
9
  : view.value.asks.map((ask) => {
10
10
  const first = ask.questions[0];
11
+ const title = `${first?.question ?? "Question"}${ask.questions.length > 1 ? ` (+${String(ask.questions.length - 1)} more)` : ""}`;
12
+ const detail = first?.options.map((option) => option.label).join(" · ") ?? "";
13
+ // An ask waiting on an Agent is calm: it never takes over the
14
+ // composer, but a person may still answer it first.
15
+ if (ask.waitingOn !== undefined)
16
+ return {
17
+ id: ask.askId,
18
+ title: `waiting on ${ask.waitingOn.name} · ${title}`,
19
+ detail,
20
+ tone: "muted",
21
+ actions: [
22
+ {
23
+ id: "answerFlow",
24
+ label: "Answer",
25
+ input: { askId: ask.askId },
26
+ },
27
+ ],
28
+ };
11
29
  return {
12
30
  id: ask.askId,
13
- title: `${first?.question ?? "Question"}${ask.questions.length > 1 ? ` (+${String(ask.questions.length - 1)} more)` : ""}`,
14
- detail: first?.options.map((option) => option.label).join(" · ") ?? "",
31
+ title,
32
+ detail,
15
33
  tone: "attention",
16
34
  actions: [
17
35
  {
18
36
  id: "answerFlow",
19
37
  label: "Answer",
20
38
  input: { askId: ask.askId },
39
+ awaiting: "question",
21
40
  },
22
41
  ],
23
42
  };