@fungi.computer/shiitake-extensions 0.1.1 → 0.3.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; humans set, pause, resume or clear it. Esc, failed work, a block or three tool-less continuations pause it. 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. |
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. |
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
@@ -90,14 +90,21 @@ metadata used for compact notes and appear as ordinary message bubbles.
90
90
 
91
91
  Goal pause stops continuation, not already accepted work. Model-requested
92
92
  completion or blocking is provisional until successful settlement; a blocked
93
- goal pauses and resumes like any paused goal. Only a live or paused goal asks
94
- before a replacement, which uses the observed revision to avoid replacing a
95
- newer goal; the replaced goal is recorded as cleared. When a runtime restart (a
96
- Hub deploy or eviction, a Local service restart) interrupts a goal turn, the
97
- turn resumes and the goal stays live. A live goal pauses when any turn of its
98
- Session fails, cannot resume or is stopped by a person (Esc), including the
99
- person's own messages ("The last turn failed"). A missing extension origin does
100
- not establish a human sender; mail and host admissions can also lack one.
93
+ goal pauses and resumes like any paused goal. Each run's goal tools are fenced
94
+ by the goal revision that run saw, so a later follow-up command (for example a
95
+ parent's message to a sub-agent) can complete, block, clear or resume a goal
96
+ that paused on its own; a goal a person paused or stopped accepts only
97
+ completion from the model, and only a person clears a live goal. When a
98
+ tool-less goal turn ends while a recorded sub-agent command is live, the goal
99
+ raises `goal-waiting` attention and rechecks on a timer instead of counting a
100
+ stall; it continues once the child settles or replies. Only a live or paused
101
+ goal asks before a replacement, which uses the observed revision to avoid
102
+ replacing a newer goal; the replaced goal is recorded as cleared. When a runtime
103
+ restart (a Hub deploy or eviction, a Local service restart) interrupts a goal
104
+ turn, the turn resumes and the goal stays live. A live goal pauses when any turn
105
+ of its Session fails, cannot resume or is stopped by a person (Esc), including
106
+ the person's own messages ("The last turn failed"). A missing extension origin
107
+ does not establish a human sender; mail and host admissions can also lack one.
101
108
 
102
109
  Goal's shared wire and durable row use `pauseCause: { kind, detail }` while
103
110
  paused and `null` while active. The closed kinds are `person`, `blocked`,
@@ -44,9 +44,12 @@ export declare const EndedGoal: Schema.Struct<{
44
44
  readonly sequence: Schema.Int;
45
45
  /** Objective that finished or was cleared. */
46
46
  readonly objective: Schema.String;
47
- /** Whether the model achieved the goal or a person cleared it. */
47
+ /** Whether the model achieved the goal or it was cleared. */
48
48
  readonly outcome: Schema.Literals<readonly ["achieved", "cleared"]>;
49
- /** The model's evidence for an achieved goal; null when cleared. */
49
+ /**
50
+ * The model's evidence for an achieved goal, or its explanation for
51
+ * clearing its own paused goal; null when a person cleared it.
52
+ */
50
53
  readonly reason: Schema.NullOr<Schema.String>;
51
54
  /** Completion time in epoch milliseconds. */
52
55
  readonly endedAt: Schema.Int;
@@ -74,7 +77,9 @@ export declare const GoalCommandInput: Schema.Union<readonly [Schema.Struct<{
74
77
  readonly confirmed: Schema.optional<Schema.Boolean>;
75
78
  }>, Schema.Struct<{
76
79
  /** Select an existing-goal control. */
77
- readonly action: Schema.Literals<readonly ["pause", "resume", "replace", "clear"]>;
80
+ readonly action: Schema.Literals<readonly ["pause", "resume", "replace", "clear", "clear-ask"]>;
81
+ /** The human's answer to "clear this goal?"; absent for a typed clear. */
82
+ readonly confirmed: Schema.optional<Schema.Boolean>;
78
83
  }>, Schema.Struct<{}>]>;
79
84
  /** Goal's contract: Session-scope human controls and one projection slot. */
80
85
  export declare const GoalProjection: Schema.Struct<{
@@ -102,9 +107,12 @@ export declare const GoalProjection: Schema.Struct<{
102
107
  readonly sequence: Schema.Int;
103
108
  /** Objective that finished or was cleared. */
104
109
  readonly objective: Schema.String;
105
- /** Whether the model achieved the goal or a person cleared it. */
110
+ /** Whether the model achieved the goal or it was cleared. */
106
111
  readonly outcome: Schema.Literals<readonly ["achieved", "cleared"]>;
107
- /** The model's evidence for an achieved goal; null when cleared. */
112
+ /**
113
+ * The model's evidence for an achieved goal, or its explanation for
114
+ * clearing its own paused goal; null when a person cleared it.
115
+ */
108
116
  readonly reason: Schema.NullOr<Schema.String>;
109
117
  /** Completion time in epoch milliseconds. */
110
118
  readonly endedAt: Schema.Int;
@@ -138,7 +146,9 @@ export declare const goalContract: {
138
146
  readonly confirmed: Schema.optional<Schema.Boolean>;
139
147
  }>, Schema.Struct<{
140
148
  /** Select an existing-goal control. */
141
- readonly action: Schema.Literals<readonly ["pause", "resume", "replace", "clear"]>;
149
+ readonly action: Schema.Literals<readonly ["pause", "resume", "replace", "clear", "clear-ask"]>;
150
+ /** The human's answer to "clear this goal?"; absent for a typed clear. */
151
+ readonly confirmed: Schema.optional<Schema.Boolean>;
142
152
  }>, Schema.Struct<{}>]>;
143
153
  /** Return the server-owned conversation step. */
144
154
  readonly output: Schema.declare<import("@fungi.computer/whistle/catalog").WhistleCatalogStepV1, import("@fungi.computer/whistle/catalog").WhistleCatalogStepV1>;
@@ -273,9 +283,12 @@ export declare const goalContract: {
273
283
  readonly sequence: Schema.Int;
274
284
  /** Objective that finished or was cleared. */
275
285
  readonly objective: Schema.String;
276
- /** Whether the model achieved the goal or a person cleared it. */
286
+ /** Whether the model achieved the goal or it was cleared. */
277
287
  readonly outcome: Schema.Literals<readonly ["achieved", "cleared"]>;
278
- /** The model's evidence for an achieved goal; null when cleared. */
288
+ /**
289
+ * The model's evidence for an achieved goal, or its explanation for
290
+ * clearing its own paused goal; null when a person cleared it.
291
+ */
279
292
  readonly reason: Schema.NullOr<Schema.String>;
280
293
  /** Completion time in epoch milliseconds. */
281
294
  readonly endedAt: Schema.Int;
@@ -46,9 +46,12 @@ export const EndedGoal = Schema.Struct({
46
46
  sequence: Schema.Int.check(Schema.isGreaterThan(0)),
47
47
  /** Objective that finished or was cleared. */
48
48
  objective: Objective,
49
- /** Whether the model achieved the goal or a person cleared it. */
49
+ /** Whether the model achieved the goal or it was cleared. */
50
50
  outcome: Schema.Literals(["achieved", "cleared"]),
51
- /** The model's evidence for an achieved goal; null when cleared. */
51
+ /**
52
+ * The model's evidence for an achieved goal, or its explanation for
53
+ * clearing its own paused goal; null when a person cleared it.
54
+ */
52
55
  reason: Schema.NullOr(GoalReason),
53
56
  /** Completion time in epoch milliseconds. */
54
57
  endedAt: EpochMillis,
@@ -76,7 +79,15 @@ export const GoalCommandInput = Schema.Union([
76
79
  }),
77
80
  Schema.Struct({
78
81
  /** Select an existing-goal control. */
79
- action: Schema.Literals(["pause", "resume", "replace", "clear"]),
82
+ action: Schema.Literals([
83
+ "pause",
84
+ "resume",
85
+ "replace",
86
+ "clear",
87
+ "clear-ask",
88
+ ]),
89
+ /** The human's answer to "clear this goal?"; absent for a typed clear. */
90
+ confirmed: Schema.optional(Schema.Boolean),
80
91
  }),
81
92
  Schema.Struct({}),
82
93
  ]);
@@ -1,5 +1,8 @@
1
1
  import { type RosteredExtensionV0 } from "@fungi.computer/shiitake/extensions";
2
+ import type { SubagentSource } from "../subagents/host.js";
2
3
  /** The commands this extension advertises to every client. */
3
4
  export declare const goalCatalog: import("@fungi.computer/shiitake/extensions").Json;
4
- /** Botanical's Session goal, driven only by settled runs. */
5
+ /** Botanical's Session goal; hosts with created-Agent facts include them in progress. */
6
+ export declare function goalExtensionFor(subagents?: SubagentSource): RosteredExtensionV0;
7
+ /** Ordinary roster when no created-Agent directory is available. */
5
8
  export declare const goalExtension: RosteredExtensionV0;
package/dist/goal/host.js CHANGED
@@ -9,16 +9,19 @@ 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
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.",
17
- moduleDescription: "Session goal tools: create_goal({objective}), get_goal({}), update_goal({status:'complete'|'blocked', reason}). " +
18
+ moduleDescription: "Session goal tools: create_goal({objective}), get_goal({}), update_goal({status:'complete'|'blocked'|'clear'|'resume', reason}). " +
18
19
  rules,
19
20
  createDescription: "Create a Session goal, including autonomously. Fails while a goal is active or paused; a finished goal is history.",
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
+ waitingSummary: "Waiting on sub-agent",
22
25
  promptLimit: 1_024,
23
26
  stallTurns: 3,
24
27
  stallReason: "Stalled: no progress for 3 turns",
@@ -54,7 +57,7 @@ const GoalRow = Schema.Struct({
54
57
  sessionId: Schema.String,
55
58
  /** A model-requested terminal, committed only by its run's success. */
56
59
  pending: Schema.NullOr(Schema.Struct({
57
- state: Schema.Literals(["complete", "blocked"]),
60
+ state: Schema.Literals(["complete", "blocked", "cleared"]),
58
61
  reason: GoalReason,
59
62
  })),
60
63
  stalledTurns: Schema.Int,
@@ -90,6 +93,7 @@ function committed(row) {
90
93
  function commitGoal(ctx, row) {
91
94
  return Effect.gen(function* () {
92
95
  yield* ctx.state.goals.put(row);
96
+ yield* ctx.attention.clear(row.sessionId, "waiting-goal");
93
97
  if (row.state === "paused" &&
94
98
  (row.pauseCause?.kind === "blocked" || row.pauseCause?.kind === "stalled"))
95
99
  yield* ctx.attention
@@ -150,6 +154,7 @@ function finishGoal(ctx, goal, outcome, reason) {
150
154
  });
151
155
  yield* ctx.state.goals.remove(goal.sessionId);
152
156
  yield* ctx.attention.clear(goal.sessionId, "paused-goal");
157
+ yield* ctx.attention.clear(goal.sessionId, "waiting-goal");
153
158
  });
154
159
  }
155
160
  /** Pause or reactivate the current goal without touching admitted work. */
@@ -197,7 +202,7 @@ function continueIfIdle(ctx, goal, requestId) {
197
202
  * settles here like any other, so the goal keeps going. An achieved goal
198
203
  * finishes and leaves the current slot.
199
204
  */
200
- function settle(ctx, run) {
205
+ function settle(ctx, run, subagents) {
201
206
  return Effect.gen(function* () {
202
207
  const found = yield* ctx.state.goals.get(run.sessionId);
203
208
  if (Option.isNone(found))
@@ -220,6 +225,9 @@ function settle(ctx, run) {
220
225
  yield* commitGoal(ctx, paused(goal, "no-terminal", ctx.config.noTerminalReason));
221
226
  return yield* ctx.observe(run.sessionId);
222
227
  }
228
+ const waitingForChild = run.toolCalls === 0 &&
229
+ subagents?.hasLiveChildCommand(run.sessionId) === true &&
230
+ run.origin?.extensionId === goalContract.id;
223
231
  const tracked = {
224
232
  ...goal,
225
233
  trackedWorkId: run.workId,
@@ -228,16 +236,34 @@ function settle(ctx, run) {
228
236
  ? run.toolCalls === 0
229
237
  ? goal.stalledTurns + 1
230
238
  : 0
231
- : goal.stalledTurns,
239
+ : 0,
232
240
  };
233
241
  const pending = goal.trackedWorkId === run.workId ? goal.pending : null;
234
242
  if (pending?.state === "complete")
235
243
  yield* finishGoal(ctx, tracked, "achieved", pending.reason);
244
+ else if (pending?.state === "cleared")
245
+ yield* finishGoal(ctx, tracked, "cleared", pending.reason);
236
246
  else if (pending?.state === "blocked")
237
247
  yield* commitGoal(ctx, paused(tracked, "blocked", pending.reason));
238
248
  else if (goal.state === "active" &&
249
+ !waitingForChild &&
239
250
  tracked.stalledTurns >= ctx.config.stallTurns)
240
251
  yield* commitGoal(ctx, paused(tracked, "stalled", ctx.config.stallReason));
252
+ else if (goal.state === "active" && waitingForChild) {
253
+ // A tool-free goal turn while a child command is live is waiting, not
254
+ // stalling: show it, and recheck on a timer so the goal resumes when the
255
+ // child settles even if no reply mail wakes this Session.
256
+ yield* commitGoal(ctx, tracked);
257
+ const now = yield* Clock.currentTimeMillis;
258
+ yield* ctx.attention.set(run.sessionId, {
259
+ id: "waiting-goal",
260
+ kind: "goal-waiting",
261
+ inbox: false,
262
+ summary: `${ctx.config.waitingSummary}: ${tracked.objective}`.slice(0, 200),
263
+ since: now,
264
+ });
265
+ yield* ctx.timers.set(waitTimerKey(run.sessionId), now + ctx.config.waitCheckMs);
266
+ }
241
267
  else if (goal.state === "active")
242
268
  yield* commitGoal(ctx, yield* continueIfIdle(ctx, tracked, `goal-continue:${run.workId}`));
243
269
  else
@@ -245,6 +271,29 @@ function settle(ctx, run) {
245
271
  yield* ctx.observe(run.sessionId);
246
272
  });
247
273
  }
274
+ const waitTimerPrefix = "waiting:";
275
+ function waitTimerKey(sessionId) {
276
+ return `${waitTimerPrefix}${sessionId}`;
277
+ }
278
+ /**
279
+ * Recheck a goal that is waiting on a child command: keep waiting while the
280
+ * child is live, otherwise admit the next goal turn (an idle-only, replayed
281
+ * admission, so a Session that already moved on is unaffected).
282
+ */
283
+ function recheckWaiting(ctx, timer, subagents) {
284
+ return Effect.gen(function* () {
285
+ if (!timer.key.startsWith(waitTimerPrefix))
286
+ return;
287
+ const sessionId = timer.key.slice(waitTimerPrefix.length);
288
+ const found = yield* ctx.state.goals.get(sessionId);
289
+ if (Option.isNone(found) || found.value.state !== "active")
290
+ return;
291
+ if (subagents?.hasLiveChildCommand(sessionId) === true)
292
+ return yield* ctx.timers.set(timer.key, timer.firedAt + ctx.config.waitCheckMs);
293
+ yield* commitGoal(ctx, yield* continueIfIdle(ctx, found.value, `goal-wait:${found.value.trackedWorkId}`));
294
+ yield* ctx.observe(sessionId);
295
+ });
296
+ }
248
297
  /** Run one tool transaction and preserve its owner-admitted diagnostic. */
249
298
  function toolCall(ctx, operation, body) {
250
299
  return Effect.runPromiseExit(ctx.transact(body)).then((exit) => {
@@ -267,9 +316,24 @@ function toolCall(ctx, operation, body) {
267
316
  }
268
317
  // Mycelium's maintained operation interface takes Zod for model-tool I/O.
269
318
  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) {
319
+ const toolState = z.enum([
320
+ "active",
321
+ "paused",
322
+ "complete",
323
+ "blocked",
324
+ "cleared",
325
+ ]);
326
+ /** The model-facing goal tools for one run, fenced by the goal it saw. */
327
+ function goalModule(ctx, sessionId, workId, initialRevision) {
328
+ const currentGoal = Effect.gen(function* () {
329
+ const found = yield* ctx.state.goals.get(sessionId);
330
+ if (Option.isNone(found))
331
+ return yield* Effect.fail(conflict("No Session goal"));
332
+ if (found.value.revision !== initialRevision &&
333
+ found.value.revision !== `run:${workId}`)
334
+ return yield* Effect.fail(conflict("Session goal work guard is stale"));
335
+ return found.value;
336
+ });
273
337
  return Mycelium.module({
274
338
  id: "botanical.session-goal",
275
339
  name: "goal",
@@ -329,35 +393,68 @@ function goalModule(ctx, sessionId, workId) {
329
393
  replay: "unsafe",
330
394
  description: ctx.config.updateDescription,
331
395
  input: z.strictObject({
332
- status: z.enum(["complete", "blocked"]),
396
+ status: z.enum(["complete", "blocked", "clear", "resume"]),
333
397
  reason: z.string().min(1).max(1_000),
334
398
  }),
335
399
  output: z.strictObject({
336
400
  objective: toolObjective,
337
- state: z.enum(["complete", "blocked"]),
401
+ state: z.enum(["active", "complete", "blocked", "cleared"]),
338
402
  reason: z.string(),
339
403
  }),
340
404
  execute: ({ status, reason }) => toolCall(ctx, "update_goal", Effect.gen(function* () {
341
405
  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"));
406
+ const goal = yield* currentGoal;
407
+ // A person's pause or stop is theirs: the model may only finish it.
408
+ if (status !== "complete" && heldByPerson(goal))
409
+ return yield* Effect.fail(conflict("A person paused or stopped this goal; only complete is available to you"));
410
+ if (status === "resume") {
411
+ const resumed = yield* modelResume(goal, workId);
412
+ yield* commitGoal(ctx, resumed);
413
+ yield* ctx.observe(sessionId);
414
+ return {
415
+ objective: goal.objective,
416
+ state: "active",
417
+ reason: decoded,
418
+ };
419
+ }
420
+ // Only a person discards a live goal; the model may clear a paused one.
421
+ if (status === "clear" && goal.state !== "paused")
422
+ return yield* Effect.fail(conflict("Only a paused Session goal can be cleared"));
423
+ const state = status === "clear" ? "cleared" : status;
347
424
  yield* commitGoal(ctx, {
348
- ...found.value,
349
- pending: { state: status, reason: decoded },
425
+ ...goal,
426
+ // This run's settlement commits the provisional terminal.
427
+ trackedWorkId: workId,
428
+ pending: { state, reason: decoded },
350
429
  });
351
- return {
352
- objective: found.value.objective,
353
- state: status,
354
- reason: decoded,
355
- };
430
+ return { objective: goal.objective, state, reason: decoded };
356
431
  })),
357
432
  }),
358
433
  },
359
434
  });
360
435
  }
436
+ /** A person's pause or Esc; only a person resumes, blocks or clears it. */
437
+ function heldByPerson(goal) {
438
+ return (goal.state === "paused" &&
439
+ (goal.pauseCause?.kind === "person" ||
440
+ goal.pauseCause?.kind === "cancelled"));
441
+ }
442
+ /**
443
+ * The model resumes its own paused goal. The resuming run becomes the
444
+ * tracked work, so its settlement admits the next goal turn.
445
+ */
446
+ function modelResume(goal, workId) {
447
+ if (goal.state !== "paused")
448
+ return Effect.fail(conflict("Session goal is not paused"));
449
+ return Effect.succeed({
450
+ ...goal,
451
+ state: "active",
452
+ pauseCause: null,
453
+ pending: null,
454
+ stalledTurns: 0,
455
+ trackedWorkId: workId,
456
+ });
457
+ }
361
458
  function pauseGoal(ctx, sessionId) {
362
459
  return changeActivity(ctx, sessionId, "paused").pipe(Effect.map(committed));
363
460
  }
@@ -420,6 +517,7 @@ function setGoal(ctx, sessionId, input) {
420
517
  return { goal: committed(row), accepted };
421
518
  });
422
519
  }
520
+ /** The one-line state the model prompt and attention summaries carry. */
423
521
  function goalLine(goal) {
424
522
  if (goal.state === "active")
425
523
  return `Pursuing goal: ${goal.objective}`;
@@ -453,21 +551,15 @@ function humanGoal(ctx, sessionId, input) {
453
551
  if (!("action" in input)) {
454
552
  if (current === undefined)
455
553
  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");
554
+ return ask(goalMenuPrompt(committed(current), yield* Clock.currentTimeMillis), {}, "action");
468
555
  }
469
556
  switch (input.action) {
557
+ case "clear-ask":
558
+ // The menu's Clear: Keep is the focused answer to this destructive step.
559
+ return ask(clearConfirmation, { action: "clear" }, "confirmed");
470
560
  case "clear":
561
+ if (input.confirmed === false)
562
+ return declined({ type: "info", message: "Kept the goal." });
471
563
  // The chat's ending line confirms a clear; only an empty one says so.
472
564
  return (yield* clearGoal(ctx, sessionId))
473
565
  ? { type: "done" }
@@ -483,7 +575,6 @@ function humanGoal(ctx, sessionId, input) {
483
575
  action: "set",
484
576
  requestId: yield* freshRequestId,
485
577
  expectedRevision: current?.revision ?? null,
486
- confirmed: true,
487
578
  }, "objective");
488
579
  case "set": {
489
580
  const retried = current?.revisionRequestId === input.requestId;
@@ -492,12 +583,7 @@ function humanGoal(ctx, sessionId, input) {
492
583
  if (replacing && input.confirmed === false)
493
584
  return declined({ type: "info", message: "Kept the current goal." });
494
585
  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
- }, {
586
+ return ask(replaceConfirmation(committed(current), input.objective), {
501
587
  action: "set",
502
588
  objective: input.objective,
503
589
  requestId: input.requestId,
@@ -556,69 +642,80 @@ export const goalCatalog = catalog([
556
642
  },
557
643
  },
558
644
  ]);
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),
645
+ /** Botanical's Session goal; hosts with created-Agent facts include them in progress. */
646
+ export function goalExtensionFor(subagents) {
647
+ return (defineExtension({
648
+ id: goalContract.id,
649
+ version: "7.0.0",
650
+ })
651
+ .config(config)
652
+ // A command anchor cannot be converted into a historical Session position.
653
+ // Old preview state must be republished separately, never guessed here.
654
+ .state({ version: 7, migrations: {}, collections })
655
+ .define({
656
+ attention: true,
657
+ prompt: (ctx, { sessionId, origin }) => Effect.gen(function* () {
658
+ const goal = Option.getOrNull(yield* ctx.state.goals.get(sessionId));
659
+ const text = goal === null
660
+ ? ctx.config.idle
661
+ : (goal.state === "active"
662
+ ? ctx.config.guide
663
+ : ctx.config.idle) +
664
+ "\n" +
665
+ goalLine(committed(goal));
666
+ return [
667
+ {
668
+ name: "goal",
669
+ text: boundedPrompt(text, ctx.config.promptLimit),
670
+ },
671
+ ...(goal?.state === "active" &&
672
+ origin?.extensionId === goalContract.id &&
673
+ origin.correlation !== null
674
+ ? [
675
+ {
676
+ name: "goal-continuation",
677
+ text: boundedPrompt(ctx.config.continuation, ctx.config.promptLimit),
678
+ },
679
+ ]
680
+ : []),
681
+ ];
682
+ }),
683
+ catalog: goalCatalog,
684
+ projection: {
685
+ schema: goalContract.projection.schema,
686
+ read: (ctx, sessionId) => {
687
+ const goal = Option.getOrUndefined(ctx.state.goals.peek(sessionId));
688
+ const endings = Option.getOrUndefined(ctx.state.endings.peek(sessionId));
689
+ if (goal === undefined && endings === undefined)
690
+ return Option.none();
691
+ const ended = endings?.ended ?? [];
692
+ return Option.some({
693
+ goal: goal === undefined ? null : committed(goal),
694
+ ended,
695
+ omitted: (endings?.total ?? 0) - ended.length,
696
+ });
581
697
  },
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
698
  },
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
- });
699
+ hooks: {
700
+ onSettled: (ctx, page) => Effect.forEach(page, (run) => settle(ctx, run, subagents), {
701
+ discard: true,
702
+ }),
703
+ onTimer: (ctx, timer) => recheckWaiting(ctx, timer, subagents),
704
+ },
705
+ tools: (ctx, { sessionId, workId }) => ctx.state.goals
706
+ .get(sessionId)
707
+ .pipe(Effect.map((found) => Option.some(goalModule(ctx, sessionId, workId, Option.isSome(found) ? found.value.revision : null)))),
708
+ commands: {
709
+ specs: goalContract.commands,
710
+ handlers: (ctx) => ({
711
+ goal: ({ sessionId, input }) => humanGoal(ctx, sessionId, input),
712
+ pause: ({ sessionId }) => pauseGoal(ctx, sessionId),
713
+ resume: ({ sessionId }) => resumeGoal(ctx, sessionId),
714
+ clear: ({ sessionId }) => clearGoal(ctx, sessionId).pipe(Effect.as({})),
715
+ set: ({ sessionId, input }) => setGoal(ctx, sessionId, input),
716
+ }),
717
+ },
718
+ }));
719
+ }
720
+ /** Ordinary roster when no created-Agent directory is available. */
721
+ export const goalExtension = goalExtensionFor();