@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
package/dist/goal/host.js CHANGED
@@ -9,16 +9,23 @@ import * as Schema from "effect/Schema";
9
9
  import { z } from "zod";
10
10
  import { ask, catalog, declined, done, freshRequestId, } from "../internal/catalog.js";
11
11
  import { EndedGoal, endedGoalLimit, Goal, GoalCommandInput, goalContract, GoalReason, Objective, } from "./contract.js";
12
+ import { clearConfirmation, goalMenuPrompt, replaceConfirmation, } from "./prompts.js";
12
13
  /** The rules the model follows; the describe page and an active goal's prompt share them. */
13
- 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 pauses until a person resumes it. Human pause/resume/clear remain human controls. Use plan.update_plan for multi-step work when available.";
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.";
14
15
  /** Hashed prose and policy: every constant goal reads comes from here. */
15
16
  const config = {
16
- 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.",
17
- moduleDescription: "Session goal tools: create_goal({objective}), get_goal({}), update_goal({status:'complete'|'blocked', 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}). " +
18
19
  rules,
19
- 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.",
20
21
  getDescription: "Read the current goal. A requested terminal state remains provisional until successful settlement.",
21
- updateDescription: "Request complete only after actual achievement, or blocked only after the same external impasse for three consecutive goal turns; settlement commits the request. Complete ends the goal; blocked pauses it for a person.",
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
+ 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,
27
+ waitingSummary: "Waiting on sub-agent",
28
+ waitingOnSummary: "Waiting",
22
29
  promptLimit: 1_024,
23
30
  stallTurns: 3,
24
31
  stallReason: "Stalled: no progress for 3 turns",
@@ -54,10 +61,21 @@ const GoalRow = Schema.Struct({
54
61
  sessionId: Schema.String,
55
62
  /** A model-requested terminal, committed only by its run's success. */
56
63
  pending: Schema.NullOr(Schema.Struct({
57
- state: Schema.Literals(["complete", "blocked"]),
64
+ state: Schema.Literals(["complete", "blocked", "cleared"]),
58
65
  reason: GoalReason,
59
66
  })),
60
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)),
61
79
  /** Request that set this revision; its retry replays. */
62
80
  revisionRequestId: Schema.String,
63
81
  /** Latest work whose settlement advances this goal. */
@@ -90,6 +108,7 @@ function committed(row) {
90
108
  function commitGoal(ctx, row) {
91
109
  return Effect.gen(function* () {
92
110
  yield* ctx.state.goals.put(row);
111
+ yield* ctx.attention.clear(row.sessionId, "waiting-goal");
93
112
  if (row.state === "paused" &&
94
113
  (row.pauseCause?.kind === "blocked" || row.pauseCause?.kind === "stalled"))
95
114
  yield* ctx.attention
@@ -123,6 +142,8 @@ function paused(goal, kind, detail) {
123
142
  state: "paused",
124
143
  pauseCause: { kind, detail },
125
144
  pending: null,
145
+ waiting: null,
146
+ successor: null,
126
147
  };
127
148
  }
128
149
  /**
@@ -150,6 +171,7 @@ function finishGoal(ctx, goal, outcome, reason) {
150
171
  });
151
172
  yield* ctx.state.goals.remove(goal.sessionId);
152
173
  yield* ctx.attention.clear(goal.sessionId, "paused-goal");
174
+ yield* ctx.attention.clear(goal.sessionId, "waiting-goal");
153
175
  });
154
176
  }
155
177
  /** Pause or reactivate the current goal without touching admitted work. */
@@ -162,6 +184,7 @@ function changeActivity(ctx, sessionId, target) {
162
184
  state: "active",
163
185
  pauseCause: null,
164
186
  stalledTurns: 0,
187
+ waiting: null,
165
188
  }
166
189
  : paused(goal, "person", null);
167
190
  yield* commitGoal(ctx, changed);
@@ -197,7 +220,7 @@ function continueIfIdle(ctx, goal, requestId) {
197
220
  * settles here like any other, so the goal keeps going. An achieved goal
198
221
  * finishes and leaves the current slot.
199
222
  */
200
- function settle(ctx, run) {
223
+ function settle(ctx, run, subagents) {
201
224
  return Effect.gen(function* () {
202
225
  const found = yield* ctx.state.goals.get(run.sessionId);
203
226
  if (Option.isNone(found))
@@ -220,24 +243,96 @@ function settle(ctx, run) {
220
243
  yield* commitGoal(ctx, paused(goal, "no-terminal", ctx.config.noTerminalReason));
221
244
  return yield* ctx.observe(run.sessionId);
222
245
  }
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;
223
259
  const tracked = {
224
260
  ...goal,
225
261
  trackedWorkId: run.workId,
226
- stalledTurns: run.origin?.extensionId === goalContract.id &&
227
- run.origin.correlation === goal.revision
228
- ? run.toolCalls === 0
229
- ? goal.stalledTurns + 1
230
- : 0
231
- : goal.stalledTurns,
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,
232
270
  };
233
271
  const pending = goal.trackedWorkId === run.workId ? goal.pending : null;
234
- if (pending?.state === "complete")
235
- yield* finishGoal(ctx, tracked, "achieved", 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
+ }
236
293
  else if (pending?.state === "blocked")
237
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
+ }
238
317
  else if (goal.state === "active" &&
318
+ !waitingForChild &&
239
319
  tracked.stalledTurns >= ctx.config.stallTurns)
240
320
  yield* commitGoal(ctx, paused(tracked, "stalled", ctx.config.stallReason));
321
+ else if (goal.state === "active" && waitingForChild) {
322
+ // A tool-free goal turn while a child command is live is waiting, not
323
+ // stalling: show it, and recheck on a timer so the goal resumes when the
324
+ // child settles even if no reply mail wakes this Session.
325
+ yield* commitGoal(ctx, tracked);
326
+ const now = yield* Clock.currentTimeMillis;
327
+ yield* ctx.attention.set(run.sessionId, {
328
+ id: "waiting-goal",
329
+ kind: "goal-waiting",
330
+ inbox: false,
331
+ summary: `${ctx.config.waitingSummary}: ${tracked.objective}`.slice(0, 200),
332
+ since: now,
333
+ });
334
+ yield* ctx.timers.set(waitTimerKey(run.sessionId), now + ctx.config.waitCheckMs);
335
+ }
241
336
  else if (goal.state === "active")
242
337
  yield* commitGoal(ctx, yield* continueIfIdle(ctx, tracked, `goal-continue:${run.workId}`));
243
338
  else
@@ -245,6 +340,37 @@ function settle(ctx, run) {
245
340
  yield* ctx.observe(run.sessionId);
246
341
  });
247
342
  }
343
+ const waitTimerPrefix = "waiting:";
344
+ function waitTimerKey(sessionId) {
345
+ return `${waitTimerPrefix}${sessionId}`;
346
+ }
347
+ /**
348
+ * Recheck a goal that is waiting on a child command: keep waiting while the
349
+ * child is live, otherwise admit the next goal turn (an idle-only, replayed
350
+ * admission, so a Session that already moved on is unaffected).
351
+ */
352
+ function recheckWaiting(ctx, timer, subagents) {
353
+ return Effect.gen(function* () {
354
+ if (!timer.key.startsWith(waitTimerPrefix))
355
+ return;
356
+ const sessionId = timer.key.slice(waitTimerPrefix.length);
357
+ const found = yield* ctx.state.goals.get(sessionId);
358
+ if (Option.isNone(found) || found.value.state !== "active")
359
+ return;
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)))
369
+ return yield* ctx.timers.set(timer.key, timer.firedAt + ctx.config.waitCheckMs);
370
+ yield* commitGoal(ctx, yield* continueIfIdle(ctx, found.value, `goal-wait:${found.value.trackedWorkId}`));
371
+ yield* ctx.observe(sessionId);
372
+ });
373
+ }
248
374
  /** Run one tool transaction and preserve its owner-admitted diagnostic. */
249
375
  function toolCall(ctx, operation, body) {
250
376
  return Effect.runPromiseExit(ctx.transact(body)).then((exit) => {
@@ -267,9 +393,24 @@ function toolCall(ctx, operation, body) {
267
393
  }
268
394
  // Mycelium's maintained operation interface takes Zod for model-tool I/O.
269
395
  const toolObjective = z.string().min(1).max(4_000);
270
- const toolState = z.enum(["active", "paused", "complete", "blocked"]);
271
- /** The model-facing goal tools for one run, guarded by that run's work id. */
272
- function goalModule(ctx, sessionId, workId) {
396
+ const toolState = z.enum([
397
+ "active",
398
+ "paused",
399
+ "complete",
400
+ "blocked",
401
+ "cleared",
402
+ ]);
403
+ /** The model-facing goal tools for one run, fenced by the goal it saw. */
404
+ function goalModule(ctx, sessionId, workId, initialRevision) {
405
+ const currentGoal = Effect.gen(function* () {
406
+ const found = yield* ctx.state.goals.get(sessionId);
407
+ if (Option.isNone(found))
408
+ return yield* Effect.fail(conflict("No Session goal"));
409
+ if (found.value.revision !== initialRevision &&
410
+ found.value.revision !== `run:${workId}`)
411
+ return yield* Effect.fail(conflict("Session goal work guard is stale"));
412
+ return found.value;
413
+ });
273
414
  return Mycelium.module({
274
415
  id: "botanical.session-goal",
275
416
  name: "goal",
@@ -285,8 +426,21 @@ function goalModule(ctx, sessionId, workId) {
285
426
  }),
286
427
  execute: ({ objective }) => toolCall(ctx, "create_goal", Effect.gen(function* () {
287
428
  const decoded = yield* Schema.decodeUnknownEffect(Objective)(objective).pipe(Effect.mapError(() => conflict("Goal objective is invalid")));
288
- if (Option.isSome(yield* ctx.state.goals.get(sessionId)))
289
- 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
+ }
290
444
  yield* commitGoal(ctx, {
291
445
  sessionId,
292
446
  objective: decoded,
@@ -298,6 +452,8 @@ function goalModule(ctx, sessionId, workId) {
298
452
  revisionRequestId: `run:${workId}`,
299
453
  trackedWorkId: workId,
300
454
  pauseCause: null,
455
+ waiting: null,
456
+ successor: null,
301
457
  });
302
458
  yield* ctx.observe(sessionId);
303
459
  return { objective: decoded, state: "active" };
@@ -325,39 +481,116 @@ function goalModule(ctx, sessionId, workId) {
325
481
  null,
326
482
  }))),
327
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
+ }),
328
528
  update_goal: Mycelium.operation({
329
529
  replay: "unsafe",
330
530
  description: ctx.config.updateDescription,
331
531
  input: z.strictObject({
332
- status: z.enum(["complete", "blocked"]),
532
+ status: z.enum(["complete", "blocked", "clear", "resume"]),
333
533
  reason: z.string().min(1).max(1_000),
334
534
  }),
335
535
  output: z.strictObject({
336
536
  objective: toolObjective,
337
- state: z.enum(["complete", "blocked"]),
537
+ state: z.enum(["active", "complete", "blocked", "cleared"]),
338
538
  reason: z.string(),
339
539
  }),
340
540
  execute: ({ status, reason }) => toolCall(ctx, "update_goal", Effect.gen(function* () {
341
541
  const decoded = yield* Schema.decodeUnknownEffect(GoalReason)(reason).pipe(Effect.mapError(() => conflict("Goal reason is invalid")));
342
- const found = yield* ctx.state.goals.get(sessionId);
343
- if (Option.isNone(found) ||
344
- found.value.trackedWorkId !== workId ||
345
- found.value.state !== "active")
346
- return yield* Effect.fail(conflict("Session goal work guard is stale"));
542
+ const goal = yield* currentGoal;
543
+ // A person's pause or stop is theirs: the model may only finish it.
544
+ if (status !== "complete" && heldByPerson(goal))
545
+ return yield* Effect.fail(conflict("A person paused or stopped this goal; only complete is available to you"));
546
+ if (status === "resume") {
547
+ const resumed = yield* modelResume(goal, workId);
548
+ yield* commitGoal(ctx, resumed);
549
+ yield* ctx.observe(sessionId);
550
+ return {
551
+ objective: goal.objective,
552
+ state: "active",
553
+ reason: decoded,
554
+ };
555
+ }
556
+ // Only a person discards a live goal; the model may clear a paused one.
557
+ if (status === "clear" && goal.state !== "paused")
558
+ return yield* Effect.fail(conflict("Only a paused Session goal can be cleared"));
559
+ const state = status === "clear" ? "cleared" : status;
347
560
  yield* commitGoal(ctx, {
348
- ...found.value,
349
- pending: { state: status, reason: decoded },
561
+ ...goal,
562
+ // This run's settlement commits the provisional terminal.
563
+ trackedWorkId: workId,
564
+ pending: { state, reason: decoded },
350
565
  });
351
- return {
352
- objective: found.value.objective,
353
- state: status,
354
- reason: decoded,
355
- };
566
+ return { objective: goal.objective, state, reason: decoded };
356
567
  })),
357
568
  }),
358
569
  },
359
570
  });
360
571
  }
572
+ /** A person's pause or Esc; only a person resumes, blocks or clears it. */
573
+ function heldByPerson(goal) {
574
+ return (goal.state === "paused" &&
575
+ (goal.pauseCause?.kind === "person" ||
576
+ goal.pauseCause?.kind === "cancelled"));
577
+ }
578
+ /**
579
+ * The model resumes its own paused goal. The resuming run becomes the
580
+ * tracked work, so its settlement admits the next goal turn.
581
+ */
582
+ function modelResume(goal, workId) {
583
+ if (goal.state !== "paused")
584
+ return Effect.fail(conflict("Session goal is not paused"));
585
+ return Effect.succeed({
586
+ ...goal,
587
+ state: "active",
588
+ pauseCause: null,
589
+ pending: null,
590
+ stalledTurns: 0,
591
+ trackedWorkId: workId,
592
+ });
593
+ }
361
594
  function pauseGoal(ctx, sessionId) {
362
595
  return changeActivity(ctx, sessionId, "paused").pipe(Effect.map(committed));
363
596
  }
@@ -377,15 +610,22 @@ function setGoal(ctx, sessionId, input) {
377
610
  const retried = current?.revisionRequestId === input.requestId;
378
611
  if (!retried && (current?.revision ?? null) !== input.expectedRevision)
379
612
  return yield* Effect.fail(conflict("Session goal changed; read it before replacing it"));
380
- const admitted = yield* ctx.send({
381
- sessionId,
382
- when: "now",
383
- prompt: {
384
- text: input.objective,
385
- requestId: input.requestId,
386
- delivery: "followUp",
387
- },
388
- });
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
+ };
389
629
  if (admitted._tag !== "Accepted")
390
630
  return yield* Effect.fail(conflict("Session goal was not admitted"));
391
631
  const accepted = {
@@ -414,12 +654,20 @@ function setGoal(ctx, sessionId, input) {
414
654
  revisionRequestId: input.requestId,
415
655
  trackedWorkId: admitted.commandId,
416
656
  pauseCause: null,
657
+ waiting: null,
658
+ successor: null,
417
659
  };
418
- 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);
419
666
  yield* ctx.observe(sessionId);
420
- return { goal: committed(row), accepted };
667
+ return { goal: committed(started), accepted };
421
668
  });
422
669
  }
670
+ /** The one-line state the model prompt and attention summaries carry. */
423
671
  function goalLine(goal) {
424
672
  if (goal.state === "active")
425
673
  return `Pursuing goal: ${goal.objective}`;
@@ -453,21 +701,15 @@ function humanGoal(ctx, sessionId, input) {
453
701
  if (!("action" in input)) {
454
702
  if (current === undefined)
455
703
  return ask(objectivePrompt, { action: "set", requestId: yield* freshRequestId }, "objective");
456
- const goal = committed(current);
457
- return ask({
458
- type: "choice",
459
- message: goalLine(goal),
460
- options: [
461
- goal.state === "active"
462
- ? { value: "pause", label: "Pause goal" }
463
- : { value: "resume", label: "Resume goal" },
464
- { value: "clear", label: "Clear goal" },
465
- { value: "replace", label: "Replace goal" },
466
- ],
467
- }, {}, "action");
704
+ return ask(goalMenuPrompt(committed(current), yield* Clock.currentTimeMillis), {}, "action");
468
705
  }
469
706
  switch (input.action) {
707
+ case "clear-ask":
708
+ // The menu's Clear: Keep is the focused answer to this destructive step.
709
+ return ask(clearConfirmation, { action: "clear" }, "confirmed");
470
710
  case "clear":
711
+ if (input.confirmed === false)
712
+ return declined({ type: "info", message: "Kept the goal." });
471
713
  // The chat's ending line confirms a clear; only an empty one says so.
472
714
  return (yield* clearGoal(ctx, sessionId))
473
715
  ? { type: "done" }
@@ -483,7 +725,6 @@ function humanGoal(ctx, sessionId, input) {
483
725
  action: "set",
484
726
  requestId: yield* freshRequestId,
485
727
  expectedRevision: current?.revision ?? null,
486
- confirmed: true,
487
728
  }, "objective");
488
729
  case "set": {
489
730
  const retried = current?.revisionRequestId === input.requestId;
@@ -492,12 +733,7 @@ function humanGoal(ctx, sessionId, input) {
492
733
  if (replacing && input.confirmed === false)
493
734
  return declined({ type: "info", message: "Kept the current goal." });
494
735
  if (replacing && input.confirmed !== true)
495
- return ask({
496
- type: "confirmation",
497
- message: `Replace the ${current.state === "active" ? "live" : "paused"} goal?\nCurrent: ${current.objective}\nNew: ${input.objective}`,
498
- confirmLabel: "Replace and start",
499
- cancelLabel: "Keep current goal",
500
- }, {
736
+ return ask(replaceConfirmation(committed(current), input.objective), {
501
737
  action: "set",
502
738
  objective: input.objective,
503
739
  requestId: input.requestId,
@@ -556,69 +792,80 @@ export const goalCatalog = catalog([
556
792
  },
557
793
  },
558
794
  ]);
559
- /** Botanical's Session goal, driven only by settled runs. */
560
- export const goalExtension = defineExtension({
561
- id: goalContract.id,
562
- version: "7.0.0",
563
- })
564
- .config(config)
565
- // A command anchor cannot be converted into a historical Session position.
566
- // Old preview state must be republished separately, never guessed here.
567
- .state({ version: 7, migrations: {}, collections })
568
- .define({
569
- attention: true,
570
- prompt: (ctx, { sessionId, origin }) => Effect.gen(function* () {
571
- const goal = Option.getOrNull(yield* ctx.state.goals.get(sessionId));
572
- const text = goal === null
573
- ? ctx.config.idle
574
- : (goal.state === "active" ? ctx.config.guide : ctx.config.idle) +
575
- "\n" +
576
- goalLine(committed(goal));
577
- return [
578
- {
579
- name: "goal",
580
- text: boundedPrompt(text, ctx.config.promptLimit),
795
+ /** Botanical's Session goal; hosts with created-Agent facts include them in progress. */
796
+ export function goalExtensionFor(subagents) {
797
+ return (defineExtension({
798
+ id: goalContract.id,
799
+ version: "7.0.0",
800
+ })
801
+ .config(config)
802
+ // A command anchor cannot be converted into a historical Session position.
803
+ // Old preview state must be republished separately, never guessed here.
804
+ .state({ version: 7, migrations: {}, collections })
805
+ .define({
806
+ attention: true,
807
+ prompt: (ctx, { sessionId, origin }) => Effect.gen(function* () {
808
+ const goal = Option.getOrNull(yield* ctx.state.goals.get(sessionId));
809
+ const text = goal === null
810
+ ? ctx.config.idle
811
+ : (goal.state === "active"
812
+ ? ctx.config.guide
813
+ : ctx.config.idle) +
814
+ "\n" +
815
+ goalLine(committed(goal));
816
+ return [
817
+ {
818
+ name: "goal",
819
+ text: boundedPrompt(text, ctx.config.promptLimit),
820
+ },
821
+ ...(goal?.state === "active" &&
822
+ origin?.extensionId === goalContract.id &&
823
+ origin.correlation !== null
824
+ ? [
825
+ {
826
+ name: "goal-continuation",
827
+ text: boundedPrompt(ctx.config.continuation, ctx.config.promptLimit),
828
+ },
829
+ ]
830
+ : []),
831
+ ];
832
+ }),
833
+ catalog: goalCatalog,
834
+ projection: {
835
+ schema: goalContract.projection.schema,
836
+ read: (ctx, sessionId) => {
837
+ const goal = Option.getOrUndefined(ctx.state.goals.peek(sessionId));
838
+ const endings = Option.getOrUndefined(ctx.state.endings.peek(sessionId));
839
+ if (goal === undefined && endings === undefined)
840
+ return Option.none();
841
+ const ended = endings?.ended ?? [];
842
+ return Option.some({
843
+ goal: goal === undefined ? null : committed(goal),
844
+ ended,
845
+ omitted: (endings?.total ?? 0) - ended.length,
846
+ });
581
847
  },
582
- ...(goal?.state === "active" &&
583
- origin?.extensionId === goalContract.id &&
584
- origin.correlation !== null
585
- ? [
586
- {
587
- name: "goal-continuation",
588
- text: boundedPrompt(ctx.config.continuation, ctx.config.promptLimit),
589
- },
590
- ]
591
- : []),
592
- ];
593
- }),
594
- catalog: goalCatalog,
595
- projection: {
596
- schema: goalContract.projection.schema,
597
- read: (ctx, sessionId) => {
598
- const goal = Option.getOrUndefined(ctx.state.goals.peek(sessionId));
599
- const endings = Option.getOrUndefined(ctx.state.endings.peek(sessionId));
600
- if (goal === undefined && endings === undefined)
601
- return Option.none();
602
- const ended = endings?.ended ?? [];
603
- return Option.some({
604
- goal: goal === undefined ? null : committed(goal),
605
- ended,
606
- omitted: (endings?.total ?? 0) - ended.length,
607
- });
608
848
  },
609
- },
610
- hooks: {
611
- onSettled: (ctx, page) => Effect.forEach(page, (run) => settle(ctx, run), { discard: true }),
612
- },
613
- tools: (ctx, { sessionId, workId }) => Effect.succeed(Option.some(goalModule(ctx, sessionId, workId))),
614
- commands: {
615
- specs: goalContract.commands,
616
- handlers: (ctx) => ({
617
- goal: ({ sessionId, input }) => humanGoal(ctx, sessionId, input),
618
- pause: ({ sessionId }) => pauseGoal(ctx, sessionId),
619
- resume: ({ sessionId }) => resumeGoal(ctx, sessionId),
620
- clear: ({ sessionId }) => clearGoal(ctx, sessionId).pipe(Effect.as({})),
621
- set: ({ sessionId, input }) => setGoal(ctx, sessionId, input),
622
- }),
623
- },
624
- });
849
+ hooks: {
850
+ onSettled: (ctx, page) => Effect.forEach(page, (run) => settle(ctx, run, subagents), {
851
+ discard: true,
852
+ }),
853
+ onTimer: (ctx, timer) => recheckWaiting(ctx, timer, subagents),
854
+ },
855
+ tools: (ctx, { sessionId, workId }) => ctx.state.goals
856
+ .get(sessionId)
857
+ .pipe(Effect.map((found) => Option.some(goalModule(ctx, sessionId, workId, Option.isSome(found) ? found.value.revision : null)))),
858
+ commands: {
859
+ specs: goalContract.commands,
860
+ handlers: (ctx) => ({
861
+ goal: ({ sessionId, input }) => humanGoal(ctx, sessionId, input),
862
+ pause: ({ sessionId }) => pauseGoal(ctx, sessionId),
863
+ resume: ({ sessionId }) => resumeGoal(ctx, sessionId),
864
+ clear: ({ sessionId }) => clearGoal(ctx, sessionId).pipe(Effect.as({})),
865
+ set: ({ sessionId, input }) => setGoal(ctx, sessionId, input),
866
+ }),
867
+ },
868
+ }));
869
+ }
870
+ /** Ordinary roster when no created-Agent directory is available. */
871
+ export const goalExtension = goalExtensionFor();