@fungi.computer/shiitake-extensions 0.3.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.
package/README.md CHANGED
@@ -65,16 +65,16 @@ helper borrows the Agent. Its owner closes it when the host's lifetime ends.
65
65
 
66
66
  ## Core concepts
67
67
 
68
- | Extension | Ownership and behavior |
69
- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
70
- | Ask | Up to four open decisions per Session. The model asks or withdraws; humans answer or decline. A live waiter receives the answer, while a parked question resumes through one follow-up. |
71
- | Goal | One live or paused objective per Session. The model creates, reads and completes it through `goal.*` tools, from any later turn of its Session; humans set, pause, resume or clear it. Esc, failed work, a block or three tool-less continuations pause it; a tool-less goal turn while a sub-agent command is live waits instead. Closing the app pauses it only if work stops without resuming; background work and runtime-auto-resumed turns keep it live. An achieved or cleared goal becomes one chat line in its history. |
72
- | Loop | One repeated prompt per Session. It runs on a fixed cron cadence or lets its own active run request the next occurrence. Commands start, pause, resume and stop it. |
73
- | Plan | One checklist per Session. `plan.update_plan` replaces it whole, with at most one step in progress and at most 50 steps; `steps: []` clears it. The human clears it with `/plan clear` or the footer row's Clear plan action, through the same owner. `/plan` opens the full checklist in the footer; updates keep only the current step and progress visible, and a finished or cleared plan leaves the footer while its history stays in state. |
74
- | Almanac | Agent-scope one-shot or recurring schedules. Each schedule targets a Session and tracks admission, retry and its next due time. |
75
- | Skills | Host-supplied, read-only skill discovery. Metadata enters the prompt; `skill.load` reads a skill body or supporting file; `/skills` lists them. |
76
- | Home | Agent-scope choice of an attached Computer. Each run captures its scoped file tools and current attachment choice. |
77
- | Subagents | A read-only footer view: one row per Agent this Session sent work to or created, with its live status (working, waiting on you, idle, failed, unavailable). A row opens that Agent's conversation, and `/agents` collapses the rows to one summary row and expands them again. It owns no relationship and no durable state. |
68
+ | Extension | Ownership and behavior |
69
+ | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
70
+ | Ask | Up to four open decisions per Session. The model asks or withdraws; humans answer or decline. A live waiter receives the answer, while a parked question resumes through one follow-up. On Local, an Ask raised during work another Agent sent waits on that sender first: it always parks, reads `waiting on <name>` instead of needing a person, and rides the child's reply to the sender as `waiting_on_you`. Only that sender's Session can answer it with `agents.answer`, or pass it to a person with `agents.escalate`; a person may always answer. An Ask the sender neither answers nor escalates goes to a person when the sender's turn ends, or after fifteen minutes. Hub sends every Ask to a person. |
71
+ | Goal | One live or paused objective per Session. The model creates, reads and completes it through `goal.*` tools, from any later turn of its Session; humans set, pause, resume or clear it. Esc, failed work, a block or three tool-less continuations pause it; a tool-less goal turn while a sub-agent command is live waits instead. Closing the app pauses it only if work stops without resuming; background work and runtime-auto-resumed turns keep it live. An achieved or cleared goal becomes one chat line in its history. |
72
+ | Loop | One repeated prompt per Session. It runs on a fixed cron cadence or lets its own active run request the next occurrence. Commands start, pause, resume and stop it. |
73
+ | Plan | One checklist per Session. `plan.update_plan` replaces it whole, with at most one step in progress and at most 50 steps; `steps: []` clears it. The human clears it with `/plan clear` or the footer row's Clear plan action, through the same owner. `/plan` opens the full checklist in the footer; updates keep only the current step and progress visible, and a finished or cleared plan leaves the footer while its history stays in state. |
74
+ | Almanac | Agent-scope one-shot or recurring schedules. Each schedule targets a Session and tracks admission, retry and its next due time. |
75
+ | Skills | Host-supplied, read-only skill discovery. Metadata enters the prompt; `skill.load` reads a skill body or supporting file; `/skills` lists them. |
76
+ | Home | Agent-scope choice of an attached Computer. Each run captures its scoped file tools and current attachment choice. |
77
+ | Subagents | A footer view: one row per Agent this Session sent work to or created, with its live status (working, waiting on an Agent, needs you, idle, failed, unavailable). A row opens that Agent's conversation, a question that needs a person can be answered from its row, and `/agents` collapses the rows to one summary row and expands them again. It owns no relationship and no durable state. |
78
78
 
79
79
  An active Goal is quiet system context on every run. Continuation instructions
80
80
  apply only to runs admitted by Goal with its continuation correlation; human
@@ -269,9 +269,14 @@ expands the footer rows, per Session and in memory).
269
269
  `source.read(sessionId, limit)` returns the Agents that Session sent work to or
270
270
  created, newest first, with a count of the rest; `source.open` is the host's
271
271
  existing Agent access. Each child's status is its owner's own Session state:
272
- `running` is working, any open attention (a question or a tool approval) is
273
- waiting on you, `done` is idle and `errored` is failed. A child whose state
274
- cannot be read is unavailable, never idle.
272
+ `running` is working, `done` is idle and `errored` is failed. Attention that
273
+ waits on a person (a question, or an approval an extension raises) is
274
+ `needs you`; a question that waits on the Agent that sent the work is
275
+ `waiting on <name>`, calm until it is escalated, and counted apart in the
276
+ collapsed summary, which is amber only when a child needs a person. A
277
+ `needs you` question carries its text and one Answer action that runs the
278
+ question's own answer conversation. A child whose state cannot be read is
279
+ unavailable, never idle.
275
280
 
276
281
  Rows refresh when a watcher reads the parent Session and they are at least a
277
282
  second old. While a row is working or waiting, they poll every five seconds
@@ -224,6 +224,12 @@ export declare const goalContract: {
224
224
  readonly expectedRevision: Schema.NullOr<Schema.String>;
225
225
  /** Idempotency key for this setting command. */
226
226
  readonly requestId: Schema.String;
227
+ /**
228
+ * Work already admitted in this Session that carries the objective,
229
+ * such as a delegated send. The goal adopts it instead of admitting
230
+ * the objective again; its settlement starts the goal turns.
231
+ */
232
+ readonly adoptCommandId: Schema.optionalKey<Schema.String>;
227
233
  }>;
228
234
  /** Return the goal and its admission receipt. */
229
235
  readonly output: Schema.Struct<{
@@ -154,6 +154,12 @@ export const goalContract = {
154
154
  expectedRevision: Schema.NullOr(Schema.String),
155
155
  /** Idempotency key for this setting command. */
156
156
  requestId: RequestId,
157
+ /**
158
+ * Work already admitted in this Session that carries the objective,
159
+ * such as a delegated send. The goal adopts it instead of admitting
160
+ * the objective again; its settlement starts the goal turns.
161
+ */
162
+ adoptCommandId: Schema.optionalKey(RequestId),
157
163
  }),
158
164
  /** Return the goal and its admission receipt. */
159
165
  output: Schema.Struct({
package/dist/goal/host.js CHANGED
@@ -14,14 +14,18 @@ import { clearConfirmation, goalMenuPrompt, replaceConfirmation, } from "./promp
14
14
  const rules = "You may start a goal yourself when none is current. Before marking complete audit every requirement against evidence; complete only after actual achievement. Block only after the same external impasse for three consecutive goal turns; a blocked goal stays paused until it is resumed. You may complete, block, clear or resume a goal that paused on its own (stalled, blocked or failed work); a goal a person paused or stopped you may only complete, and only a person clears a live goal. Human pause/resume/clear remain human controls. Use plan.update_plan for multi-step work when available.";
15
15
  /** Hashed prose and policy: every constant goal reads comes from here. */
16
16
  const config = {
17
- continuation: "Continue working toward the current goal described in the goal section. Find goal.get_goal and goal.update_goal with forage.search({query:'goal'}) and forage.describe. Audit every requirement against evidence before goal.update_goal({status:'complete', reason}). If the same external blocker persists for three goal turns, report blocked with a reason. Check for progress rather than repeating yourself.",
18
- moduleDescription: "Session goal tools: create_goal({objective}), get_goal({}), update_goal({status:'complete'|'blocked'|'clear'|'resume', reason}). " +
17
+ continuation: "Continue working toward the current goal described in the goal section. Find goal.get_goal and goal.update_goal with forage.search({query:'goal'}) and forage.describe. Audit every requirement against evidence before goal.update_goal({status:'complete', reason}). If the same external blocker persists for three goal turns, report blocked with a reason. Check for progress rather than repeating yourself. If there is nothing to do until something outside this turn happens (a child's reply, a person, the train, main), call goal.wait({reason, fallbackMinutes}) and end the turn instead of polling.",
18
+ moduleDescription: "Session goal tools: create_goal({objective}), get_goal({}), update_goal({status:'complete'|'blocked'|'clear'|'resume', reason}), wait({reason, fallbackMinutes}). " +
19
19
  rules,
20
- createDescription: "Create a Session goal, including autonomously. Fails while a goal is active or paused; a finished goal is history.",
20
+ createDescription: "Create a Session goal, including autonomously. Fails while a goal is active or paused; a finished goal is history. After update_goal complete (or clear on a paused goal) in this same run, it queues the replacement, which takes over when this run settles.",
21
21
  getDescription: "Read the current goal. A requested terminal state remains provisional until successful settlement.",
22
22
  updateDescription: "Request complete only after actual achievement, or blocked only after the same external impasse for three consecutive goal turns; both also work on a paused goal, and settlement commits the request. Complete ends the goal; blocked pauses it for a person. clear abandons a paused goal with your reason. resume reactivates a paused goal now. A goal a person paused or stopped accepts only complete.",
23
23
  waitCheckMs: 60_000,
24
+ waitDescription: "Say the goal is waiting on something outside this turn, then end the turn. The goal stays active and admits no goal turn and no stall count until another run in this Session settles (a child's reply, a person's message, a loop tick) or fallbackMinutes (default 20, 1 to 60) pass. Use it instead of polling.",
25
+ waitDefaultMinutes: 20,
26
+ waitMaxMinutes: 60,
24
27
  waitingSummary: "Waiting on sub-agent",
28
+ waitingOnSummary: "Waiting",
25
29
  promptLimit: 1_024,
26
30
  stallTurns: 3,
27
31
  stallReason: "Stalled: no progress for 3 turns",
@@ -61,6 +65,17 @@ const GoalRow = Schema.Struct({
61
65
  reason: GoalReason,
62
66
  })),
63
67
  stalledTurns: Schema.Int,
68
+ /**
69
+ * The goal waits on something outside its turns. `until` is null while the
70
+ * requesting run is still running; its settlement fixes the fallback instant.
71
+ */
72
+ waiting: Schema.optionalKey(Schema.NullOr(Schema.Struct({
73
+ reason: GoalReason,
74
+ fallbackMs: Schema.Int,
75
+ until: Schema.NullOr(Schema.Int),
76
+ }))),
77
+ /** A replacement objective queued after this run's complete or clear. */
78
+ successor: Schema.optionalKey(Schema.NullOr(Objective)),
64
79
  /** Request that set this revision; its retry replays. */
65
80
  revisionRequestId: Schema.String,
66
81
  /** Latest work whose settlement advances this goal. */
@@ -127,6 +142,8 @@ function paused(goal, kind, detail) {
127
142
  state: "paused",
128
143
  pauseCause: { kind, detail },
129
144
  pending: null,
145
+ waiting: null,
146
+ successor: null,
130
147
  };
131
148
  }
132
149
  /**
@@ -167,6 +184,7 @@ function changeActivity(ctx, sessionId, target) {
167
184
  state: "active",
168
185
  pauseCause: null,
169
186
  stalledTurns: 0,
187
+ waiting: null,
170
188
  }
171
189
  : paused(goal, "person", null);
172
190
  yield* commitGoal(ctx, changed);
@@ -225,26 +243,77 @@ function settle(ctx, run, subagents) {
225
243
  yield* commitGoal(ctx, paused(goal, "no-terminal", ctx.config.noTerminalReason));
226
244
  return yield* ctx.observe(run.sessionId);
227
245
  }
228
- const waitingForChild = run.toolCalls === 0 &&
229
- subagents?.hasLiveChildCommand(run.sessionId) === true &&
230
- run.origin?.extensionId === goalContract.id;
246
+ const goalTurn = run.origin?.extensionId === goalContract.id;
247
+ // A live child command or a question or approval a person has yet to
248
+ // answer is a legitimate wait, whether or not this turn polled.
249
+ const waitingForChild = goalTurn &&
250
+ (subagents?.hasLiveChildCommand(run.sessionId) === true ||
251
+ (yield* ctx.attention.awaitsPerson(run.sessionId)));
252
+ const waiting = goal.waiting ?? null;
253
+ // The run that called goal.wait fixes the fallback instant as it settles.
254
+ const requested = waiting !== null &&
255
+ waiting.until === null &&
256
+ run.workId === goal.trackedWorkId;
257
+ // Any other run that settles while a wait is armed wakes the goal.
258
+ const woken = waiting !== null && waiting.until !== null;
231
259
  const tracked = {
232
260
  ...goal,
233
261
  trackedWorkId: run.workId,
234
- stalledTurns: run.origin?.extensionId === goalContract.id &&
235
- run.origin.correlation === goal.revision
236
- ? run.toolCalls === 0
237
- ? goal.stalledTurns + 1
238
- : 0
239
- : 0,
262
+ stalledTurns: requested
263
+ ? goal.stalledTurns
264
+ : run.origin?.extensionId === goalContract.id &&
265
+ run.origin.correlation === goal.revision
266
+ ? run.toolCalls === 0
267
+ ? goal.stalledTurns + 1
268
+ : 0
269
+ : 0,
240
270
  };
241
271
  const pending = goal.trackedWorkId === run.workId ? goal.pending : null;
242
- if (pending?.state === "complete")
243
- yield* finishGoal(ctx, tracked, "achieved", pending.reason);
244
- else if (pending?.state === "cleared")
245
- yield* finishGoal(ctx, tracked, "cleared", pending.reason);
272
+ if (pending?.state === "complete" || pending?.state === "cleared") {
273
+ yield* ctx.timers.cancel(waitTimerKey(run.sessionId));
274
+ yield* finishGoal(ctx, tracked, pending.state === "complete" ? "achieved" : "cleared", pending.reason);
275
+ if (goal.successor != null) {
276
+ const next = {
277
+ sessionId: run.sessionId,
278
+ objective: goal.successor,
279
+ state: "active",
280
+ startedAt: yield* Clock.currentTimeMillis,
281
+ pending: null,
282
+ stalledTurns: 0,
283
+ revision: `run:${run.workId}`,
284
+ revisionRequestId: `run:${run.workId}`,
285
+ trackedWorkId: run.workId,
286
+ pauseCause: null,
287
+ waiting: null,
288
+ successor: null,
289
+ };
290
+ yield* commitGoal(ctx, yield* continueIfIdle(ctx, next, `goal-continue:${run.workId}`));
291
+ }
292
+ }
246
293
  else if (pending?.state === "blocked")
247
294
  yield* commitGoal(ctx, paused(tracked, "blocked", pending.reason));
295
+ else if (goal.state === "active" && requested) {
296
+ // goal.wait: no continuation and no stall; the next settled run or the
297
+ // fallback timer wakes the goal.
298
+ const now = yield* Clock.currentTimeMillis;
299
+ const until = now + waiting.fallbackMs;
300
+ yield* commitGoal(ctx, {
301
+ ...tracked,
302
+ waiting: { ...waiting, until },
303
+ });
304
+ yield* ctx.attention.set(run.sessionId, {
305
+ id: "waiting-goal",
306
+ kind: "goal-waiting",
307
+ inbox: false,
308
+ summary: `${ctx.config.waitingOnSummary}: ${waiting.reason}`.slice(0, 200),
309
+ since: now,
310
+ });
311
+ yield* ctx.timers.set(waitTimerKey(run.sessionId), until);
312
+ }
313
+ else if (goal.state === "active" && woken) {
314
+ yield* ctx.timers.cancel(waitTimerKey(run.sessionId));
315
+ yield* commitGoal(ctx, yield* continueIfIdle(ctx, { ...tracked, waiting: null }, `goal-continue:${run.workId}`));
316
+ }
248
317
  else if (goal.state === "active" &&
249
318
  !waitingForChild &&
250
319
  tracked.stalledTurns >= ctx.config.stallTurns)
@@ -288,7 +357,15 @@ function recheckWaiting(ctx, timer, subagents) {
288
357
  const found = yield* ctx.state.goals.get(sessionId);
289
358
  if (Option.isNone(found) || found.value.state !== "active")
290
359
  return;
291
- if (subagents?.hasLiveChildCommand(sessionId) === true)
360
+ const armed = found.value.waiting ?? null;
361
+ if (armed !== null) {
362
+ // goal.wait's fallback: a re-armed wait ignores a stale firing.
363
+ if (armed.until === null || armed.until > timer.firedAt)
364
+ return;
365
+ return yield* commitGoal(ctx, yield* continueIfIdle(ctx, { ...found.value, waiting: null, stalledTurns: 0 }, `goal-wait:${found.value.trackedWorkId}`)).pipe(Effect.andThen(ctx.observe(sessionId)));
366
+ }
367
+ if (subagents?.hasLiveChildCommand(sessionId) === true ||
368
+ (yield* ctx.attention.awaitsPerson(sessionId)))
292
369
  return yield* ctx.timers.set(timer.key, timer.firedAt + ctx.config.waitCheckMs);
293
370
  yield* commitGoal(ctx, yield* continueIfIdle(ctx, found.value, `goal-wait:${found.value.trackedWorkId}`));
294
371
  yield* ctx.observe(sessionId);
@@ -349,8 +426,21 @@ function goalModule(ctx, sessionId, workId, initialRevision) {
349
426
  }),
350
427
  execute: ({ objective }) => toolCall(ctx, "create_goal", Effect.gen(function* () {
351
428
  const decoded = yield* Schema.decodeUnknownEffect(Objective)(objective).pipe(Effect.mapError(() => conflict("Goal objective is invalid")));
352
- if (Option.isSome(yield* ctx.state.goals.get(sessionId)))
353
- return yield* Effect.fail(conflict("A Session goal is already active or paused; only a person replaces it"));
429
+ const existing = yield* ctx.state.goals.get(sessionId);
430
+ if (Option.isSome(existing)) {
431
+ // A goal this run already finished (complete, or clear of a
432
+ // paused one) hands over to the replacement at settlement.
433
+ const retiring = existing.value.trackedWorkId === workId &&
434
+ (existing.value.pending?.state === "complete" ||
435
+ existing.value.pending?.state === "cleared");
436
+ if (!retiring)
437
+ return yield* Effect.fail(conflict("A Session goal is already active or paused; complete it first, or only a person replaces it"));
438
+ yield* ctx.state.goals.put({
439
+ ...existing.value,
440
+ successor: decoded,
441
+ });
442
+ return { objective: decoded, state: "active" };
443
+ }
354
444
  yield* commitGoal(ctx, {
355
445
  sessionId,
356
446
  objective: decoded,
@@ -362,6 +452,8 @@ function goalModule(ctx, sessionId, workId, initialRevision) {
362
452
  revisionRequestId: `run:${workId}`,
363
453
  trackedWorkId: workId,
364
454
  pauseCause: null,
455
+ waiting: null,
456
+ successor: null,
365
457
  });
366
458
  yield* ctx.observe(sessionId);
367
459
  return { objective: decoded, state: "active" };
@@ -389,6 +481,50 @@ function goalModule(ctx, sessionId, workId, initialRevision) {
389
481
  null,
390
482
  }))),
391
483
  }),
484
+ wait: Mycelium.operation({
485
+ replay: "unsafe",
486
+ description: ctx.config.waitDescription,
487
+ input: z.strictObject({
488
+ reason: z.string().min(1).max(1_000),
489
+ fallbackMinutes: z
490
+ .number()
491
+ .int()
492
+ .min(1)
493
+ .max(ctx.config.waitMaxMinutes)
494
+ .optional(),
495
+ }),
496
+ output: z.strictObject({
497
+ objective: toolObjective,
498
+ state: z.literal("active"),
499
+ reason: z.string(),
500
+ fallbackMinutes: z.number().int(),
501
+ }),
502
+ execute: ({ reason, fallbackMinutes }) => toolCall(ctx, "wait", Effect.gen(function* () {
503
+ const decoded = yield* Schema.decodeUnknownEffect(GoalReason)(reason).pipe(Effect.mapError(() => conflict("Wait reason is invalid")));
504
+ const goal = yield* currentGoal;
505
+ if (goal.state !== "active")
506
+ return yield* Effect.fail(conflict("Only an active Session goal can wait"));
507
+ if (goal.pending != null)
508
+ return yield* Effect.fail(conflict("This run already requested a goal terminal"));
509
+ const minutes = fallbackMinutes ?? ctx.config.waitDefaultMinutes;
510
+ yield* ctx.state.goals.put({
511
+ ...goal,
512
+ // This run's settlement starts the wait.
513
+ trackedWorkId: workId,
514
+ waiting: {
515
+ reason: decoded,
516
+ fallbackMs: minutes * 60_000,
517
+ until: null,
518
+ },
519
+ });
520
+ return {
521
+ objective: goal.objective,
522
+ state: "active",
523
+ reason: decoded,
524
+ fallbackMinutes: minutes,
525
+ };
526
+ })),
527
+ }),
392
528
  update_goal: Mycelium.operation({
393
529
  replay: "unsafe",
394
530
  description: ctx.config.updateDescription,
@@ -474,15 +610,22 @@ function setGoal(ctx, sessionId, input) {
474
610
  const retried = current?.revisionRequestId === input.requestId;
475
611
  if (!retried && (current?.revision ?? null) !== input.expectedRevision)
476
612
  return yield* Effect.fail(conflict("Session goal changed; read it before replacing it"));
477
- const admitted = yield* ctx.send({
478
- sessionId,
479
- when: "now",
480
- prompt: {
481
- text: input.objective,
482
- requestId: input.requestId,
483
- delivery: "followUp",
484
- },
485
- });
613
+ // Adopted work already carries the objective; anything else admits it.
614
+ const admitted = input.adoptCommandId === undefined
615
+ ? yield* ctx.send({
616
+ sessionId,
617
+ when: "now",
618
+ prompt: {
619
+ text: input.objective,
620
+ requestId: input.requestId,
621
+ delivery: "followUp",
622
+ },
623
+ })
624
+ : {
625
+ _tag: "Accepted",
626
+ commandId: input.adoptCommandId,
627
+ delivery: retried ? "replay" : "new",
628
+ };
486
629
  if (admitted._tag !== "Accepted")
487
630
  return yield* Effect.fail(conflict("Session goal was not admitted"));
488
631
  const accepted = {
@@ -511,10 +654,17 @@ function setGoal(ctx, sessionId, input) {
511
654
  revisionRequestId: input.requestId,
512
655
  trackedWorkId: admitted.commandId,
513
656
  pauseCause: null,
657
+ waiting: null,
658
+ successor: null,
514
659
  };
515
- yield* commitGoal(ctx, row);
660
+ // Adopted work that already settled left the Session idle: start the
661
+ // first goal turn now. Otherwise its settlement does.
662
+ const started = input.adoptCommandId === undefined
663
+ ? row
664
+ : yield* continueIfIdle(ctx, row, `goal-adopt:${input.requestId}`);
665
+ yield* commitGoal(ctx, started);
516
666
  yield* ctx.observe(sessionId);
517
- return { goal: committed(row), accepted };
667
+ return { goal: committed(started), accepted };
518
668
  });
519
669
  }
520
670
  /** The one-line state the model prompt and attention summaries carry. */
@@ -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
  };