@fungi.computer/shiitake-extensions 0.3.0 → 0.5.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 (39) hide show
  1. package/README.md +20 -16
  2. package/dist/goal/contract.d.ts +6 -0
  3. package/dist/goal/contract.js +6 -0
  4. package/dist/goal/host.js +183 -31
  5. package/dist/loop/contract.d.ts +136 -2
  6. package/dist/loop/contract.js +45 -2
  7. package/dist/loop/host.js +23 -0
  8. package/dist/question/answer-flow.d.ts +27 -0
  9. package/dist/question/answer-flow.js +115 -0
  10. package/dist/question/client.d.ts +1 -1
  11. package/dist/question/client.js +22 -3
  12. package/dist/question/contract.d.ts +202 -60
  13. package/dist/question/contract.js +78 -22
  14. package/dist/question/host.d.ts +4 -0
  15. package/dist/question/host.js +348 -339
  16. package/dist/roster/conversation-agents.d.ts +21 -1
  17. package/dist/roster/conversation-agents.js +47 -2
  18. package/dist/roster.d.ts +5 -2
  19. package/dist/roster.js +16 -5
  20. package/dist/schedules.d.ts +178 -0
  21. package/dist/schedules.js +182 -0
  22. package/dist/subagents/client.js +64 -20
  23. package/dist/subagents/contract.d.ts +123 -10
  24. package/dist/subagents/contract.js +38 -5
  25. package/dist/subagents/host.d.ts +2 -4
  26. package/dist/subagents/host.js +152 -22
  27. package/docs/api/README.md +1 -0
  28. package/docs/api/goal/contract.md +1 -1
  29. package/docs/api/goal/host.md +22 -1
  30. package/docs/api/loop/contract.md +43 -31
  31. package/docs/api/question/client.md +2 -1
  32. package/docs/api/question/contract.md +183 -51
  33. package/docs/api/question/host.md +30 -0
  34. package/docs/api/roster/conversation-agents.md +34 -8
  35. package/docs/api/roster.md +39 -2
  36. package/docs/api/schedules.md +481 -0
  37. package/docs/api/subagents/contract.md +101 -28
  38. package/docs/api/subagents/host.md +5 -3
  39. package/package.json +9 -6
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
@@ -112,9 +112,8 @@ paused and `null` while active. The closed kinds are `person`, `blocked`,
112
112
  text, never a discriminator. Only blocked/stalled goals emit `goal-blocked` or
113
113
  `goal-stalled` attention, kept out of the main inbox with `inbox: false`.
114
114
  Questions and these two Goal kinds ask for a person in Herdr activity;
115
- person-paused, cancelled and failed goals do not. State version 6 discards
116
- disposable preview Goal rows from versions 4 and 5 rather than reading the
117
- superseded representation.
115
+ person-paused, cancelled and failed goals do not. The current Goal state is
116
+ version 7. Superseded preview Goal rows are not migrated to this representation.
118
117
 
119
118
  Loop tools are offered in every run. `loop.get`, `loop.start` and `loop.stop`
120
119
  work from any run of the Session, and the model may start a loop itself.
@@ -269,9 +268,14 @@ expands the footer rows, per Session and in memory).
269
268
  `source.read(sessionId, limit)` returns the Agents that Session sent work to or
270
269
  created, newest first, with a count of the rest; `source.open` is the host's
271
270
  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.
271
+ `running` is working, `done` is idle and `errored` is failed. Attention that
272
+ waits on a person (a question, or an approval an extension raises) is
273
+ `needs you`; a question that waits on the Agent that sent the work is
274
+ `waiting on <name>`, calm until it is escalated, and counted apart in the
275
+ collapsed summary, which is amber only when a child needs a person. A
276
+ `needs you` question carries its text and one Answer action that runs the
277
+ question's own answer conversation. A child whose state cannot be read is
278
+ unavailable, never idle.
275
279
 
276
280
  Rows refresh when a watcher reads the parent Session and they are at least a
277
281
  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);
@@ -190,7 +208,9 @@ function continueIfIdle(ctx, goal, requestId) {
190
208
  })
191
209
  .pipe(Effect.map((admitted) => admitted._tag === "Accepted"
192
210
  ? { ...goal, trackedWorkId: admitted.commandId }
193
- : goal), Effect.catchTag("AdmissionError", (error) => Effect.succeed(paused(goal, "failed", error.message))));
211
+ : goal), Effect.catchTag("AdmissionError", (error) => error.reason === "archived"
212
+ ? Clock.currentTimeMillis.pipe(Effect.flatMap((now) => ctx.timers.set(waitTimerKey(goal.sessionId), now + ctx.config.waitCheckMs)), Effect.as(goal))
213
+ : Effect.succeed(paused(goal, "failed", error.message))));
194
214
  }
195
215
  /**
196
216
  * Advance one Session's goal from one settled run. Any successful run in
@@ -225,26 +245,77 @@ function settle(ctx, run, subagents) {
225
245
  yield* commitGoal(ctx, paused(goal, "no-terminal", ctx.config.noTerminalReason));
226
246
  return yield* ctx.observe(run.sessionId);
227
247
  }
228
- const waitingForChild = run.toolCalls === 0 &&
229
- subagents?.hasLiveChildCommand(run.sessionId) === true &&
230
- run.origin?.extensionId === goalContract.id;
248
+ const goalTurn = run.origin?.extensionId === goalContract.id;
249
+ // A live child command or a question or approval a person has yet to
250
+ // answer is a legitimate wait, whether or not this turn polled.
251
+ const waitingForChild = goalTurn &&
252
+ (subagents?.hasLiveChildCommand(run.sessionId) === true ||
253
+ (yield* ctx.attention.awaitsPerson(run.sessionId)));
254
+ const waiting = goal.waiting ?? null;
255
+ // The run that called goal.wait fixes the fallback instant as it settles.
256
+ const requested = waiting !== null &&
257
+ waiting.until === null &&
258
+ run.workId === goal.trackedWorkId;
259
+ // Any other run that settles while a wait is armed wakes the goal.
260
+ const woken = waiting !== null && waiting.until !== null;
231
261
  const tracked = {
232
262
  ...goal,
233
263
  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,
264
+ stalledTurns: requested
265
+ ? goal.stalledTurns
266
+ : run.origin?.extensionId === goalContract.id &&
267
+ run.origin.correlation === goal.revision
268
+ ? run.toolCalls === 0
269
+ ? goal.stalledTurns + 1
270
+ : 0
271
+ : 0,
240
272
  };
241
273
  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);
274
+ if (pending?.state === "complete" || pending?.state === "cleared") {
275
+ yield* ctx.timers.cancel(waitTimerKey(run.sessionId));
276
+ yield* finishGoal(ctx, tracked, pending.state === "complete" ? "achieved" : "cleared", pending.reason);
277
+ if (goal.successor != null) {
278
+ const next = {
279
+ sessionId: run.sessionId,
280
+ objective: goal.successor,
281
+ state: "active",
282
+ startedAt: yield* Clock.currentTimeMillis,
283
+ pending: null,
284
+ stalledTurns: 0,
285
+ revision: `run:${run.workId}`,
286
+ revisionRequestId: `run:${run.workId}`,
287
+ trackedWorkId: run.workId,
288
+ pauseCause: null,
289
+ waiting: null,
290
+ successor: null,
291
+ };
292
+ yield* commitGoal(ctx, yield* continueIfIdle(ctx, next, `goal-continue:${run.workId}`));
293
+ }
294
+ }
246
295
  else if (pending?.state === "blocked")
247
296
  yield* commitGoal(ctx, paused(tracked, "blocked", pending.reason));
297
+ else if (goal.state === "active" && requested) {
298
+ // goal.wait: no continuation and no stall; the next settled run or the
299
+ // fallback timer wakes the goal.
300
+ const now = yield* Clock.currentTimeMillis;
301
+ const until = now + waiting.fallbackMs;
302
+ yield* commitGoal(ctx, {
303
+ ...tracked,
304
+ waiting: { ...waiting, until },
305
+ });
306
+ yield* ctx.attention.set(run.sessionId, {
307
+ id: "waiting-goal",
308
+ kind: "goal-waiting",
309
+ inbox: false,
310
+ summary: `${ctx.config.waitingOnSummary}: ${waiting.reason}`.slice(0, 200),
311
+ since: now,
312
+ });
313
+ yield* ctx.timers.set(waitTimerKey(run.sessionId), until);
314
+ }
315
+ else if (goal.state === "active" && woken) {
316
+ yield* ctx.timers.cancel(waitTimerKey(run.sessionId));
317
+ yield* commitGoal(ctx, yield* continueIfIdle(ctx, { ...tracked, waiting: null }, `goal-continue:${run.workId}`));
318
+ }
248
319
  else if (goal.state === "active" &&
249
320
  !waitingForChild &&
250
321
  tracked.stalledTurns >= ctx.config.stallTurns)
@@ -288,7 +359,15 @@ function recheckWaiting(ctx, timer, subagents) {
288
359
  const found = yield* ctx.state.goals.get(sessionId);
289
360
  if (Option.isNone(found) || found.value.state !== "active")
290
361
  return;
291
- if (subagents?.hasLiveChildCommand(sessionId) === true)
362
+ const armed = found.value.waiting ?? null;
363
+ if (armed !== null) {
364
+ // goal.wait's fallback: a re-armed wait ignores a stale firing.
365
+ if (armed.until === null || armed.until > timer.firedAt)
366
+ return;
367
+ return yield* commitGoal(ctx, yield* continueIfIdle(ctx, { ...found.value, waiting: null, stalledTurns: 0 }, `goal-wait:${found.value.trackedWorkId}`)).pipe(Effect.andThen(ctx.observe(sessionId)));
368
+ }
369
+ if (subagents?.hasLiveChildCommand(sessionId) === true ||
370
+ (yield* ctx.attention.awaitsPerson(sessionId)))
292
371
  return yield* ctx.timers.set(timer.key, timer.firedAt + ctx.config.waitCheckMs);
293
372
  yield* commitGoal(ctx, yield* continueIfIdle(ctx, found.value, `goal-wait:${found.value.trackedWorkId}`));
294
373
  yield* ctx.observe(sessionId);
@@ -349,8 +428,21 @@ function goalModule(ctx, sessionId, workId, initialRevision) {
349
428
  }),
350
429
  execute: ({ objective }) => toolCall(ctx, "create_goal", Effect.gen(function* () {
351
430
  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"));
431
+ const existing = yield* ctx.state.goals.get(sessionId);
432
+ if (Option.isSome(existing)) {
433
+ // A goal this run already finished (complete, or clear of a
434
+ // paused one) hands over to the replacement at settlement.
435
+ const retiring = existing.value.trackedWorkId === workId &&
436
+ (existing.value.pending?.state === "complete" ||
437
+ existing.value.pending?.state === "cleared");
438
+ if (!retiring)
439
+ return yield* Effect.fail(conflict("A Session goal is already active or paused; complete it first, or only a person replaces it"));
440
+ yield* ctx.state.goals.put({
441
+ ...existing.value,
442
+ successor: decoded,
443
+ });
444
+ return { objective: decoded, state: "active" };
445
+ }
354
446
  yield* commitGoal(ctx, {
355
447
  sessionId,
356
448
  objective: decoded,
@@ -362,6 +454,8 @@ function goalModule(ctx, sessionId, workId, initialRevision) {
362
454
  revisionRequestId: `run:${workId}`,
363
455
  trackedWorkId: workId,
364
456
  pauseCause: null,
457
+ waiting: null,
458
+ successor: null,
365
459
  });
366
460
  yield* ctx.observe(sessionId);
367
461
  return { objective: decoded, state: "active" };
@@ -389,6 +483,50 @@ function goalModule(ctx, sessionId, workId, initialRevision) {
389
483
  null,
390
484
  }))),
391
485
  }),
486
+ wait: Mycelium.operation({
487
+ replay: "unsafe",
488
+ description: ctx.config.waitDescription,
489
+ input: z.strictObject({
490
+ reason: z.string().min(1).max(1_000),
491
+ fallbackMinutes: z
492
+ .number()
493
+ .int()
494
+ .min(1)
495
+ .max(ctx.config.waitMaxMinutes)
496
+ .optional(),
497
+ }),
498
+ output: z.strictObject({
499
+ objective: toolObjective,
500
+ state: z.literal("active"),
501
+ reason: z.string(),
502
+ fallbackMinutes: z.number().int(),
503
+ }),
504
+ execute: ({ reason, fallbackMinutes }) => toolCall(ctx, "wait", Effect.gen(function* () {
505
+ const decoded = yield* Schema.decodeUnknownEffect(GoalReason)(reason).pipe(Effect.mapError(() => conflict("Wait reason is invalid")));
506
+ const goal = yield* currentGoal;
507
+ if (goal.state !== "active")
508
+ return yield* Effect.fail(conflict("Only an active Session goal can wait"));
509
+ if (goal.pending != null)
510
+ return yield* Effect.fail(conflict("This run already requested a goal terminal"));
511
+ const minutes = fallbackMinutes ?? ctx.config.waitDefaultMinutes;
512
+ yield* ctx.state.goals.put({
513
+ ...goal,
514
+ // This run's settlement starts the wait.
515
+ trackedWorkId: workId,
516
+ waiting: {
517
+ reason: decoded,
518
+ fallbackMs: minutes * 60_000,
519
+ until: null,
520
+ },
521
+ });
522
+ return {
523
+ objective: goal.objective,
524
+ state: "active",
525
+ reason: decoded,
526
+ fallbackMinutes: minutes,
527
+ };
528
+ })),
529
+ }),
392
530
  update_goal: Mycelium.operation({
393
531
  replay: "unsafe",
394
532
  description: ctx.config.updateDescription,
@@ -474,15 +612,22 @@ function setGoal(ctx, sessionId, input) {
474
612
  const retried = current?.revisionRequestId === input.requestId;
475
613
  if (!retried && (current?.revision ?? null) !== input.expectedRevision)
476
614
  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
- });
615
+ // Adopted work already carries the objective; anything else admits it.
616
+ const admitted = input.adoptCommandId === undefined
617
+ ? yield* ctx.send({
618
+ sessionId,
619
+ when: "now",
620
+ prompt: {
621
+ text: input.objective,
622
+ requestId: input.requestId,
623
+ delivery: "followUp",
624
+ },
625
+ })
626
+ : {
627
+ _tag: "Accepted",
628
+ commandId: input.adoptCommandId,
629
+ delivery: retried ? "replay" : "new",
630
+ };
486
631
  if (admitted._tag !== "Accepted")
487
632
  return yield* Effect.fail(conflict("Session goal was not admitted"));
488
633
  const accepted = {
@@ -511,10 +656,17 @@ function setGoal(ctx, sessionId, input) {
511
656
  revisionRequestId: input.requestId,
512
657
  trackedWorkId: admitted.commandId,
513
658
  pauseCause: null,
659
+ waiting: null,
660
+ successor: null,
514
661
  };
515
- yield* commitGoal(ctx, row);
662
+ // Adopted work that already settled left the Session idle: start the
663
+ // first goal turn now. Otherwise its settlement does.
664
+ const started = input.adoptCommandId === undefined
665
+ ? row
666
+ : yield* continueIfIdle(ctx, row, `goal-adopt:${input.requestId}`);
667
+ yield* commitGoal(ctx, started);
516
668
  yield* ctx.observe(sessionId);
517
- return { goal: committed(row), accepted };
669
+ return { goal: committed(started), accepted };
518
670
  });
519
671
  }
520
672
  /** The one-line state the model prompt and attention summaries carry. */
@@ -93,12 +93,146 @@ export declare const LoopProjection: Schema.Struct<{
93
93
  readonly consecutiveFailures: Schema.Int;
94
94
  }>;
95
95
  }>;
96
- /** Session-owned loop controls and their visible state. */
96
+ /** Session-owned current-Loop controls and Agent-scoped identified-Loop controls. */
97
97
  export declare const loopContract: {
98
98
  /** Stable extension identity. */
99
99
  readonly id: string & import("effect/Brand").Brand<"ExtensionId">;
100
- /** Loop operations admitted by the Session owner. */
100
+ /** Loop operations admitted by their Agent or Session owner. */
101
101
  readonly commands: {
102
+ /** Read an Agent's bounded Loop page, including each destination Session. */
103
+ readonly list: {
104
+ /** The Agent owns the collection of its Sessions' Loops. */
105
+ readonly scope: "agent";
106
+ /** Page strictly after a Session identity, in identity order. */
107
+ readonly input: Schema.Struct<{
108
+ /** Exclusive Session cursor. */
109
+ readonly after: Schema.optionalKey<Schema.String>;
110
+ /** Maximum number of Loop rows to read. */
111
+ readonly limit: Schema.optionalKey<Schema.Int>;
112
+ }>;
113
+ /** Public Loop values with Session targets, never private scheduling state. */
114
+ readonly output: Schema.$Array<Schema.Struct<{
115
+ /** Session that owns this identified Loop. */
116
+ readonly sessionId: Schema.String;
117
+ /** Identity of the loop whose occurrences are being tracked. */
118
+ readonly loopId: Schema.String;
119
+ /** Whether a person or the model started this loop. */
120
+ readonly origin: Schema.Literals<readonly ["human", "model"]>;
121
+ /** Configured expiry timestamp, or null when absent. */
122
+ readonly expiresAt: Schema.NullOr<Schema.Int>;
123
+ /** Original instruction repeated for every occurrence. */
124
+ readonly prompt: Schema.String;
125
+ /** Whether the model or a fixed cron cadence schedules occurrences. */
126
+ readonly mode: Schema.Literals<readonly ["self_paced", "fixed_interval"]>;
127
+ /** Cadence of a fixed-interval loop; null when the model paces it. */
128
+ readonly cron: Schema.NullOr<Schema.Struct<{
129
+ /** Cron expression for the fixed cadence. */
130
+ readonly expression: Schema.String;
131
+ /** IANA time zone in which the expression is evaluated. */
132
+ readonly timeZone: Schema.String;
133
+ }>>;
134
+ /** Current loop lifecycle state. */
135
+ readonly state: Schema.Literals<readonly ["active", "paused", "finished", "failed", "cancelled"]>;
136
+ /** Latest accepted command associated with this loop. */
137
+ readonly commandId: Schema.String;
138
+ /** The model's stated reason for the next self-paced occurrence. */
139
+ readonly reason: Schema.NullOr<Schema.String>;
140
+ /** When the next occurrence is due, if one is scheduled. */
141
+ readonly nextDueAt: Schema.NullOr<Schema.Int>;
142
+ /** Visible explanation when loop work failed. */
143
+ readonly lastError: Schema.NullOr<Schema.String>;
144
+ /** Consecutive failed work outcomes; successful work resets this to zero. */
145
+ readonly consecutiveFailures: Schema.Int;
146
+ }>>;
147
+ };
148
+ /** Pause an identified Loop; refuse a stale target after replacement. */
149
+ readonly pauseLoop: {
150
+ /** The Agent owns the identified Loop and its Session. */
151
+ readonly scope: "agent";
152
+ /** The listed Session and Loop identities must both still match. */
153
+ readonly input: Schema.Struct<{
154
+ /** Session containing the identified Loop. */
155
+ readonly sessionId: Schema.String;
156
+ /** Exact Loop identity, not whichever Loop later replaces it. */
157
+ readonly loopId: Schema.String;
158
+ }>;
159
+ /** Return the paused Loop without cancelling accepted work. */
160
+ readonly output: Schema.Struct<{
161
+ /** Identity of the loop whose occurrences are being tracked. */
162
+ readonly loopId: Schema.String;
163
+ /** Whether a person or the model started this loop. */
164
+ readonly origin: Schema.Literals<readonly ["human", "model"]>;
165
+ /** Configured expiry timestamp, or null when absent. */
166
+ readonly expiresAt: Schema.NullOr<Schema.Int>;
167
+ /** Original instruction repeated for every occurrence. */
168
+ readonly prompt: Schema.String;
169
+ /** Whether the model or a fixed cron cadence schedules occurrences. */
170
+ readonly mode: Schema.Literals<readonly ["self_paced", "fixed_interval"]>;
171
+ /** Cadence of a fixed-interval loop; null when the model paces it. */
172
+ readonly cron: Schema.NullOr<Schema.Struct<{
173
+ /** Cron expression for the fixed cadence. */
174
+ readonly expression: Schema.String;
175
+ /** IANA time zone in which the expression is evaluated. */
176
+ readonly timeZone: Schema.String;
177
+ }>>;
178
+ /** Current loop lifecycle state. */
179
+ readonly state: Schema.Literals<readonly ["active", "paused", "finished", "failed", "cancelled"]>;
180
+ /** Latest accepted command associated with this loop. */
181
+ readonly commandId: Schema.String;
182
+ /** The model's stated reason for the next self-paced occurrence. */
183
+ readonly reason: Schema.NullOr<Schema.String>;
184
+ /** When the next occurrence is due, if one is scheduled. */
185
+ readonly nextDueAt: Schema.NullOr<Schema.Int>;
186
+ /** Visible explanation when loop work failed. */
187
+ readonly lastError: Schema.NullOr<Schema.String>;
188
+ /** Consecutive failed work outcomes; successful work resets this to zero. */
189
+ readonly consecutiveFailures: Schema.Int;
190
+ }>;
191
+ };
192
+ /** Cancel an identified Loop's future occurrences, retaining accepted work. */
193
+ readonly stopLoop: {
194
+ /** The Agent owns the identified Loop and its Session. */
195
+ readonly scope: "agent";
196
+ /** The listed Session and Loop identities must both still match. */
197
+ readonly input: Schema.Struct<{
198
+ /** Session containing the identified Loop. */
199
+ readonly sessionId: Schema.String;
200
+ /** Exact Loop identity, not whichever Loop later replaces it. */
201
+ readonly loopId: Schema.String;
202
+ }>;
203
+ /** Return the cancelled Loop; its Session and history remain. */
204
+ readonly output: Schema.Struct<{
205
+ /** Identity of the loop whose occurrences are being tracked. */
206
+ readonly loopId: Schema.String;
207
+ /** Whether a person or the model started this loop. */
208
+ readonly origin: Schema.Literals<readonly ["human", "model"]>;
209
+ /** Configured expiry timestamp, or null when absent. */
210
+ readonly expiresAt: Schema.NullOr<Schema.Int>;
211
+ /** Original instruction repeated for every occurrence. */
212
+ readonly prompt: Schema.String;
213
+ /** Whether the model or a fixed cron cadence schedules occurrences. */
214
+ readonly mode: Schema.Literals<readonly ["self_paced", "fixed_interval"]>;
215
+ /** Cadence of a fixed-interval loop; null when the model paces it. */
216
+ readonly cron: Schema.NullOr<Schema.Struct<{
217
+ /** Cron expression for the fixed cadence. */
218
+ readonly expression: Schema.String;
219
+ /** IANA time zone in which the expression is evaluated. */
220
+ readonly timeZone: Schema.String;
221
+ }>>;
222
+ /** Current loop lifecycle state. */
223
+ readonly state: Schema.Literals<readonly ["active", "paused", "finished", "failed", "cancelled"]>;
224
+ /** Latest accepted command associated with this loop. */
225
+ readonly commandId: Schema.String;
226
+ /** The model's stated reason for the next self-paced occurrence. */
227
+ readonly reason: Schema.NullOr<Schema.String>;
228
+ /** When the next occurrence is due, if one is scheduled. */
229
+ readonly nextDueAt: Schema.NullOr<Schema.Int>;
230
+ /** Visible explanation when loop work failed. */
231
+ readonly lastError: Schema.NullOr<Schema.String>;
232
+ /** Consecutive failed work outcomes; successful work resets this to zero. */
233
+ readonly consecutiveFailures: Schema.Int;
234
+ }>;
235
+ };
102
236
  /** Open or answer the human loop controls. */
103
237
  readonly loop: {
104
238
  /** The Session owns this loop. */
@@ -8,6 +8,7 @@ import { CatalogStep } from "../internal/catalog.js";
8
8
  */
9
9
  const Prompt = Schema.String.pipe(Schema.check(Schema.isMinLength(1)), Schema.check(Schema.isMaxLength(256 * 1024)));
10
10
  const RequestId = Schema.String.pipe(Schema.check(Schema.isMinLength(1)), Schema.check(Schema.isMaxLength(200)));
11
+ const SessionId = Schema.String.pipe(Schema.check(Schema.isMinLength(1)), Schema.check(Schema.isMaxLength(256)));
11
12
  /** A named cron cadence evaluated in one IANA time zone. */
12
13
  export const LoopCron = Schema.Struct({
13
14
  /** Cron expression for the fixed cadence. */
@@ -93,12 +94,54 @@ export const LoopProjection = Schema.Struct({
93
94
  /** Loop currently visible to Session clients. */
94
95
  loop: Loop,
95
96
  });
96
- /** Session-owned loop controls and their visible state. */
97
+ const LoopTarget = Schema.Struct({
98
+ /** Session containing the identified Loop. */
99
+ sessionId: SessionId,
100
+ /** Exact Loop identity, not whichever Loop later replaces it. */
101
+ loopId: Loop.fields.loopId,
102
+ });
103
+ /** Session-owned current-Loop controls and Agent-scoped identified-Loop controls. */
97
104
  export const loopContract = {
98
105
  /** Stable extension identity. */
99
106
  id: ExtensionId.make("botanical-session-loop"),
100
- /** Loop operations admitted by the Session owner. */
107
+ /** Loop operations admitted by their Agent or Session owner. */
101
108
  commands: {
109
+ /** Read an Agent's bounded Loop page, including each destination Session. */
110
+ list: {
111
+ /** The Agent owns the collection of its Sessions' Loops. */
112
+ scope: "agent",
113
+ /** Page strictly after a Session identity, in identity order. */
114
+ input: Schema.Struct({
115
+ /** Exclusive Session cursor. */
116
+ after: Schema.optionalKey(SessionId),
117
+ /** Maximum number of Loop rows to read. */
118
+ limit: Schema.optionalKey(Schema.Int.pipe(Schema.check(Schema.isBetween({ minimum: 1, maximum: 64 })))),
119
+ }),
120
+ /** Public Loop values with Session targets, never private scheduling state. */
121
+ output: Schema.Array(Schema.Struct({
122
+ ...Loop.fields,
123
+ /** Session that owns this identified Loop. */
124
+ sessionId: SessionId,
125
+ })).pipe(Schema.check(Schema.isMaxLength(64))),
126
+ },
127
+ /** Pause an identified Loop; refuse a stale target after replacement. */
128
+ pauseLoop: {
129
+ /** The Agent owns the identified Loop and its Session. */
130
+ scope: "agent",
131
+ /** The listed Session and Loop identities must both still match. */
132
+ input: LoopTarget,
133
+ /** Return the paused Loop without cancelling accepted work. */
134
+ output: Loop,
135
+ },
136
+ /** Cancel an identified Loop's future occurrences, retaining accepted work. */
137
+ stopLoop: {
138
+ /** The Agent owns the identified Loop and its Session. */
139
+ scope: "agent",
140
+ /** The listed Session and Loop identities must both still match. */
141
+ input: LoopTarget,
142
+ /** Return the cancelled Loop; its Session and history remain. */
143
+ output: Loop,
144
+ },
102
145
  /** Open or answer the human loop controls. */
103
146
  loop: {
104
147
  /** The Session owns this loop. */