@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
@@ -1,31 +1,38 @@
1
1
  import { type AgentControlDirectory } from "@fungi.computer/shiitake/agent-control";
2
2
  import type { CapabilitySource } from "@fungi.computer/mycelium";
3
+ import { type ShiitakeSession } from "@fungi.computer/shiitake/client";
3
4
  import type { WatchdogSqliteOwner } from "@fungi.computer/watchdog";
4
- import { type SubagentSource } from "../subagents/host.js";
5
+ import type { SubagentSource } from "../subagents/host.js";
5
6
  /** Host-pack creation admission; attachments/model selection retain their own owners. */
6
7
  export declare function assertOrdinaryAgentCreation(input: Parameters<NonNullable<AgentControlDirectory["create"]>>[0]): void;
8
+ /** The directory a conversation sends through; opening a recipient only needs its Sessions' extension doors. */
9
+ type SendingDirectory = Omit<AgentControlDirectory, "open"> & Readonly<{
10
+ open: (agentId: string, signal: AbortSignal) => Promise<Readonly<{
11
+ agent: Readonly<{
12
+ session: (sessionId: string) => Pick<ShiitakeSession, "extension">;
13
+ }>;
14
+ release: () => Promise<void>;
15
+ }>>;
16
+ }>;
7
17
  /**
8
18
  * This host's own record of the Agents each conversation created or sent
9
19
  * accepted work to, written as its Agent-control directory accepts them
10
- * (Wiki: agent-runtime-tools-and-clients, read-only sub-agent view). It feeds
11
- * the footer only; it owns no relationship and grants nothing.
20
+ * (Wiki: agent-runtime-tools-and-clients, read-only sub-agent view). Accepted
21
+ * child command receipts are separately reconciled against their owner's
22
+ * settlement records for the parent's goal guard. Neither record grants
23
+ * authority over a child.
12
24
  */
13
25
  export declare function createConversationAgents(storage: WatchdogSqliteOwner): {
14
26
  /** Bind acceptance recording to this conversation's existing directory. */
15
- recording: (parentSessionId: string | undefined, directory: AgentControlDirectory) => AgentControlDirectory;
27
+ recording: <Directory extends SendingDirectory>(parentSessionId: string | undefined, directory: Directory) => Directory;
16
28
  /** Agent-control tools bound to the conversation that acquires them. */
17
29
  control: (directoryFor: (parentSessionId: string | undefined) => AgentControlDirectory) => CapabilitySource;
18
30
  /** The footer rows for one conversation; archived and unknown Agents leave it. */
19
- source: (directory: Pick<AgentControlDirectory, "list" | "open">) => SubagentSource;
31
+ source: (directory: Pick<AgentControlDirectory, "list"> & Pick<SubagentSource, "open">) => SubagentSource;
20
32
  };
21
33
  /** Both hosts compose the ordinary roster and its read-only created-Agent footer. */
22
- export declare function oysterAgentExtensions(source: SubagentSource): {
23
- /** Ordinary extensions plus the read-only created-Agent footer. */
24
- roster: import("@fungi.computer/shiitake").RosteredExtensionV0[];
25
- /** Host grants corresponding to the composed extension roster. */
26
- grants: Readonly<{
27
- id: import("@fungi.computer/shiitake/extensions").ExtensionId;
28
- capabilities: readonly import("@fungi.computer/shiitake").ExtensionCapabilityV0[];
29
- grantKey?: string;
30
- }>[];
31
- };
34
+ export declare function oysterAgentExtensions(source: SubagentSource): Readonly<{
35
+ roster: readonly import("@fungi.computer/shiitake").RosteredExtensionV0[];
36
+ grants: readonly import("@fungi.computer/shiitake").ExtensionGrantV0[];
37
+ }>;
38
+ export {};
@@ -1,7 +1,9 @@
1
1
  import { AgentCreateRefused, createAgentControlSource, } from "@fungi.computer/shiitake/agent-control";
2
+ import { bindSessionExtension, } from "@fungi.computer/shiitake/client";
3
+ import * as Effect from "effect/Effect";
2
4
  import * as Schema from "effect/Schema";
3
- import { botanicalAgentExtensions } from "../roster.js";
4
- import { subagentsExtension, subagentsGrant, } from "../subagents/host.js";
5
+ import { botanicalAgentExtensions, botanicalExtensionsWithSubagents, } from "../roster.js";
6
+ import { goalContract } from "../goal/contract.js";
5
7
  /** Host-pack creation admission; attachments/model selection retain their own owners. */
6
8
  export function assertOrdinaryAgentCreation(input) {
7
9
  if (input.model !== undefined)
@@ -14,11 +16,34 @@ const conversationAgentRow = Schema.Struct({
14
16
  agent_id: Schema.String,
15
17
  session_id: Schema.NullOr(Schema.String),
16
18
  });
19
+ const delegatedCommandRow = Schema.Struct({
20
+ parent_session_id: Schema.String,
21
+ agent_id: Schema.String,
22
+ session_id: Schema.String,
23
+ command_id: Schema.String,
24
+ at: Schema.Number,
25
+ });
26
+ /** Private safety ceilings for command-status polling; not a delegation policy. */
27
+ const maxWatchedCommands = 64;
28
+ const pollMs = 2_000;
29
+ const maxWatchMs = 30 * 60_000;
30
+ /** The objective of a delegated lane: the named goal, else the send's first line. */
31
+ function laneObjective(text, goal) {
32
+ const named = goal?.trim();
33
+ if (named !== undefined && named !== "")
34
+ return named;
35
+ return (text
36
+ .split("\n")
37
+ .map((line) => line.trim())
38
+ .find((line) => line !== "") ?? text.trim()).slice(0, 4_000);
39
+ }
17
40
  /**
18
41
  * This host's own record of the Agents each conversation created or sent
19
42
  * accepted work to, written as its Agent-control directory accepts them
20
- * (Wiki: agent-runtime-tools-and-clients, read-only sub-agent view). It feeds
21
- * the footer only; it owns no relationship and grants nothing.
43
+ * (Wiki: agent-runtime-tools-and-clients, read-only sub-agent view). Accepted
44
+ * child command receipts are separately reconciled against their owner's
45
+ * settlement records for the parent's goal guard. Neither record grants
46
+ * authority over a child.
22
47
  */
23
48
  export function createConversationAgents(storage) {
24
49
  // Composing a host does no storage I/O; the first record or read prepares it.
@@ -27,6 +52,7 @@ export function createConversationAgents(storage) {
27
52
  if (!prepared) {
28
53
  storage.sql.exec("CREATE TABLE IF NOT EXISTS oyster_conversation_agents (parent_session_id TEXT NOT NULL, agent_id TEXT NOT NULL, session_id TEXT, at INTEGER NOT NULL, PRIMARY KEY (parent_session_id, agent_id)) STRICT");
29
54
  storage.sql.exec("CREATE INDEX IF NOT EXISTS idx_oyster_conversation_agents_parent ON oyster_conversation_agents(parent_session_id, at)");
55
+ storage.sql.exec("CREATE TABLE IF NOT EXISTS oyster_delegated_commands (parent_session_id TEXT NOT NULL, agent_id TEXT NOT NULL, session_id TEXT NOT NULL, command_id TEXT NOT NULL PRIMARY KEY, at INTEGER NOT NULL) STRICT");
30
56
  prepared = true;
31
57
  }
32
58
  return storage.sql;
@@ -36,8 +62,12 @@ export function createConversationAgents(storage) {
36
62
  sql().exec("INSERT OR IGNORE INTO oyster_conversation_agents(parent_session_id, agent_id, session_id, at) VALUES (?, ?, NULL, ?)", parentSessionId, agentId, Date.now());
37
63
  }
38
64
  /** A send may not know its Session until the recipient accepts it. */
39
- function sent(parentSessionId, agentId, sessionId) {
65
+ function sent(parentSessionId, agentId, sessionId, commandId) {
40
66
  sql().exec("INSERT INTO oyster_conversation_agents(parent_session_id, agent_id, session_id, at) VALUES (?, ?, ?, ?) ON CONFLICT(parent_session_id, agent_id) DO UPDATE SET session_id = excluded.session_id, at = excluded.at", parentSessionId, agentId, sessionId ?? null, Date.now());
67
+ if (sessionId !== undefined) {
68
+ sql().exec("INSERT OR REPLACE INTO oyster_delegated_commands(parent_session_id, agent_id, session_id, command_id, at) VALUES (?, ?, ?, ?, ?)", parentSessionId, agentId, sessionId, commandId, Date.now());
69
+ sql().exec("DELETE FROM oyster_delegated_commands WHERE command_id NOT IN (SELECT command_id FROM oyster_delegated_commands ORDER BY at DESC LIMIT ?)", maxWatchedCommands);
70
+ }
41
71
  }
42
72
  /** One conversation's directory: its accepted creates and sends are recorded. */
43
73
  function recording(parentSessionId, directory) {
@@ -57,7 +87,31 @@ export function createConversationAgents(storage) {
57
87
  ...recorded,
58
88
  send: async (task, sourceSessionId, signal, invocation) => {
59
89
  const accepted = await send(task, sourceSessionId, signal, invocation);
60
- sent(sourceSessionId, task.agentId, task.sessionId);
90
+ if (accepted.status === "accepted") {
91
+ const sessionId = accepted.sessionId ?? task.sessionId;
92
+ sent(sourceSessionId, task.agentId, sessionId, accepted.commandId);
93
+ // A send that opens a new Session puts it under a goal, so the
94
+ // lane keeps working until it reports complete or blocked. A
95
+ // retry replays the same request id, so the goal is set once.
96
+ if (task.delivery === "followUp" &&
97
+ task.sessionId === undefined &&
98
+ task.goal !== false &&
99
+ sessionId !== undefined) {
100
+ const opened = await directory.open(task.agentId, signal);
101
+ try {
102
+ await bindSessionExtension(opened.agent.session(sessionId), goalContract).call("set", {
103
+ objective: laneObjective(task.text, task.goal),
104
+ expectedRevision: null,
105
+ requestId: `lane-goal:${accepted.commandId}`,
106
+ // The send is the lane's first turn; never repeat it.
107
+ adoptCommandId: accepted.commandId,
108
+ });
109
+ }
110
+ finally {
111
+ await opened.release();
112
+ }
113
+ }
114
+ }
61
115
  return accepted;
62
116
  },
63
117
  };
@@ -75,40 +129,110 @@ export function createConversationAgents(storage) {
75
129
  },
76
130
  }),
77
131
  /** The footer rows for one conversation; archived and unknown Agents leave it. */
78
- source: (directory) => ({
79
- read: async (sessionId, limit) => {
80
- const rows = sql()
81
- .exec("SELECT agent_id, session_id FROM oyster_conversation_agents WHERE parent_session_id = ? ORDER BY at DESC, agent_id", sessionId)
82
- .toArray()
83
- .map((row) => Schema.decodeUnknownSync(conversationAgentRow)(row));
84
- const known = new Map((await directory.list(new AbortController().signal)).map((agent) => [
85
- agent.agentId,
86
- agent.name,
87
- ]));
88
- const agents = rows.flatMap((row) => {
89
- const name = known.get(row.agent_id);
90
- return name === undefined
91
- ? []
92
- : [{ agentId: row.agent_id, name, sessionId: row.session_id }];
93
- });
94
- return {
95
- agents: agents.slice(0, limit),
96
- more: Math.max(0, agents.length - limit),
132
+ source: (directory) => {
133
+ const polling = new Set();
134
+ const signal = new AbortController().signal;
135
+ function poll(commandId) {
136
+ if (polling.has(commandId) || polling.size >= maxWatchedCommands)
137
+ return;
138
+ polling.add(commandId);
139
+ const check = async () => {
140
+ let raw;
141
+ try {
142
+ raw = sql()
143
+ .exec("SELECT parent_session_id, agent_id, session_id, command_id, at FROM oyster_delegated_commands WHERE command_id = ?", commandId)
144
+ .toArray()[0];
145
+ }
146
+ catch (cause) {
147
+ // The host's store was closed; no orphan poller may keep using it.
148
+ Effect.runFork(Effect.logError("subagents.command-store-read", cause));
149
+ polling.delete(commandId);
150
+ return;
151
+ }
152
+ if (raw === undefined) {
153
+ polling.delete(commandId);
154
+ return;
155
+ }
156
+ const row = Schema.decodeUnknownSync(delegatedCommandRow)(raw);
157
+ // An unobservable command cannot justify an indefinite live wait.
158
+ if (Date.now() - row.at >= maxWatchMs) {
159
+ sql().exec("DELETE FROM oyster_delegated_commands WHERE command_id = ?", commandId);
160
+ polling.delete(commandId);
161
+ return;
162
+ }
163
+ try {
164
+ const opened = await directory.open(row.agent_id, signal);
165
+ let settled;
166
+ try {
167
+ settled = await opened.agent
168
+ .session(row.session_id)
169
+ .readSettlement(commandId, {
170
+ timeoutMs: 0,
171
+ signal,
172
+ });
173
+ }
174
+ finally {
175
+ await opened.release();
176
+ }
177
+ if (settled !== undefined) {
178
+ sql().exec("DELETE FROM oyster_delegated_commands WHERE command_id = ?", commandId);
179
+ polling.delete(commandId);
180
+ return;
181
+ }
182
+ }
183
+ catch (cause) {
184
+ // An unreadable child is not proof of live work; do not hold the guard.
185
+ Effect.runFork(Effect.logError("subagents.command-status", cause));
186
+ try {
187
+ sql().exec("DELETE FROM oyster_delegated_commands WHERE command_id = ?", commandId);
188
+ }
189
+ catch (closed) {
190
+ // Host teardown closed storage while the remote read was pending.
191
+ Effect.runFork(Effect.logError("subagents.command-store-closed", closed));
192
+ }
193
+ polling.delete(commandId);
194
+ return;
195
+ }
196
+ setTimeout(() => void check(), pollMs);
97
197
  };
98
- },
99
- open: directory.open,
100
- }),
198
+ void check();
199
+ }
200
+ return {
201
+ // Polling is demand-driven: only a parent whose goal asks about its
202
+ // live children watches their receipts, including ones recorded
203
+ // before a restart. Composing a host or sending mail does no polling.
204
+ hasLiveChildCommand: (sessionId) => {
205
+ const live = sql()
206
+ .exec("SELECT command_id FROM oyster_delegated_commands WHERE parent_session_id = ? AND at > ? ORDER BY at DESC LIMIT ?", sessionId, Date.now() - maxWatchMs, maxWatchedCommands)
207
+ .toArray()
208
+ .map((row) => Schema.decodeUnknownSync(Schema.Struct({ command_id: Schema.String }))(row).command_id);
209
+ for (const commandId of live)
210
+ poll(commandId);
211
+ return live.length > 0;
212
+ },
213
+ read: async (sessionId, limit) => {
214
+ const rows = sql()
215
+ .exec("SELECT agent_id, session_id FROM oyster_conversation_agents WHERE parent_session_id = ? ORDER BY at DESC, agent_id", sessionId)
216
+ .toArray()
217
+ .map((row) => Schema.decodeUnknownSync(conversationAgentRow)(row));
218
+ const known = new Map((await directory.list(new AbortController().signal)).map((agent) => [agent.agentId, agent.name]));
219
+ const agents = rows.flatMap((row) => {
220
+ const name = known.get(row.agent_id);
221
+ return name === undefined
222
+ ? []
223
+ : [{ agentId: row.agent_id, name, sessionId: row.session_id }];
224
+ });
225
+ return {
226
+ agents: agents.slice(0, limit),
227
+ more: Math.max(0, agents.length - limit),
228
+ };
229
+ },
230
+ open: directory.open,
231
+ };
232
+ },
101
233
  };
102
234
  }
103
235
  /** Both hosts compose the ordinary roster and its read-only created-Agent footer. */
104
236
  export function oysterAgentExtensions(source) {
105
- return {
106
- /** Ordinary extensions plus the read-only created-Agent footer. */
107
- roster: [
108
- ...botanicalAgentExtensions.roster,
109
- subagentsExtension({ source }),
110
- ],
111
- /** Host grants corresponding to the composed extension roster. */
112
- grants: [...botanicalAgentExtensions.grants, subagentsGrant],
113
- };
237
+ return botanicalExtensionsWithSubagents(botanicalAgentExtensions, source);
114
238
  }
package/dist/roster.d.ts CHANGED
@@ -1,9 +1,15 @@
1
1
  import type { ExtensionRosterV0 } from "@fungi.computer/shiitake/extensions";
2
2
  import type { SkillSource } from "./skills/source.js";
3
+ import { type SubagentSource } from "./subagents/host.js";
3
4
  /**
4
- * Botanical's product roster with explicit grants. Hub and Local pass this
5
- * same value; a stranger host passes its own (or an empty) roster.
5
+ * Botanical's shared roster with explicit grants. Local composes its
6
+ * sender-routing question extension through `botanicalExtensionsWithSkills`;
7
+ * hosts without that Local capability send every Ask to a person.
6
8
  */
7
9
  export declare const botanicalAgentExtensions: ExtensionRosterV0;
10
+ /** Compose the goal and footer over one source; an absent goal is a wiring error. */
11
+ export declare function botanicalExtensionsWithSubagents(base: ExtensionRosterV0, source: SubagentSource): ExtensionRosterV0;
8
12
  /** A host with a SkillSource grants read-only skill discovery for its own runs. */
9
13
  export declare function botanicalExtensionsWithSkills(source: SkillSource): ExtensionRosterV0;
14
+ /** Route Asks to the Agent that sent the work, only in a host that owns the Local answer door. */
15
+ export declare function botanicalExtensionsWithSenderRouting(): ExtensionRosterV0;
package/dist/roster.js CHANGED
@@ -1,17 +1,19 @@
1
1
  import { almanacContract } from "./almanac/contract.js";
2
2
  import { almanacExtension } from "./almanac/host.js";
3
3
  import { questionContract } from "./question/contract.js";
4
- import { questionExtension } from "./question/host.js";
4
+ import { localQuestionExtension, questionExtension } from "./question/host.js";
5
5
  import { goalContract } from "./goal/contract.js";
6
- import { goalExtension } from "./goal/host.js";
6
+ import { goalExtension, goalExtensionFor } from "./goal/host.js";
7
7
  import { loopContract } from "./loop/contract.js";
8
8
  import { loopExtension } from "./loop/host.js";
9
9
  import { planContract } from "./plan/contract.js";
10
10
  import { planExtension } from "./plan/host.js";
11
11
  import { skillsExtension, skillsGrant } from "./skills/host.js";
12
+ import { subagentsExtension, subagentsGrant, } from "./subagents/host.js";
12
13
  /**
13
- * Botanical's product roster with explicit grants. Hub and Local pass this
14
- * same value; a stranger host passes its own (or an empty) roster.
14
+ * Botanical's shared roster with explicit grants. Local composes its
15
+ * sender-routing question extension through `botanicalExtensionsWithSkills`;
16
+ * hosts without that Local capability send every Ask to a person.
15
17
  */
16
18
  export const botanicalAgentExtensions = Object.freeze({
17
19
  roster: Object.freeze([
@@ -42,6 +44,7 @@ export const botanicalAgentExtensions = Object.freeze({
42
44
  "state",
43
45
  "settled",
44
46
  "send",
47
+ "timers",
45
48
  "commands",
46
49
  "projection",
47
50
  "run_tools",
@@ -72,10 +75,34 @@ export const botanicalAgentExtensions = Object.freeze({
72
75
  },
73
76
  ]),
74
77
  });
78
+ /** Compose the goal and footer over one source; an absent goal is a wiring error. */
79
+ export function botanicalExtensionsWithSubagents(base, source) {
80
+ if (!base.roster.some((extension) => extension.manifest.id === goalContract.id))
81
+ throw new Error("Cannot attach sub-agents without the goal extension");
82
+ return {
83
+ roster: [
84
+ ...base.roster.map((extension) => extension.manifest.id === goalContract.id
85
+ ? goalExtensionFor(source)
86
+ : extension),
87
+ subagentsExtension({ source }),
88
+ ],
89
+ grants: [...base.grants, subagentsGrant],
90
+ };
91
+ }
75
92
  /** A host with a SkillSource grants read-only skill discovery for its own runs. */
76
93
  export function botanicalExtensionsWithSkills(source) {
94
+ const local = botanicalExtensionsWithSenderRouting();
95
+ return {
96
+ roster: [...local.roster, skillsExtension({ source })],
97
+ grants: [...local.grants, skillsGrant],
98
+ };
99
+ }
100
+ /** Route Asks to the Agent that sent the work, only in a host that owns the Local answer door. */
101
+ export function botanicalExtensionsWithSenderRouting() {
77
102
  return {
78
- roster: [...botanicalAgentExtensions.roster, skillsExtension({ source })],
79
- grants: [...botanicalAgentExtensions.grants, skillsGrant],
103
+ ...botanicalAgentExtensions,
104
+ roster: botanicalAgentExtensions.roster.map((extension) => extension.manifest.id === questionContract.id
105
+ ? localQuestionExtension
106
+ : extension),
80
107
  };
81
108
  }
@@ -130,11 +130,29 @@ function skillsStep(found, name) {
130
130
  });
131
131
  return ask({
132
132
  type: "choice",
133
- message: `${String(found.skills.length)} ${found.skills.length === 1 ? "skill" : "skills"} discovered${skipped}`,
133
+ message: `Skills (${String(found.skills.length)})`,
134
+ summary: reasons.length === 0
135
+ ? undefined
136
+ : `${String(reasons.length)} ${reasons.length === 1 ? "skill was" : "skills were"} not loaded. Details shows why.`,
137
+ details: [
138
+ ...reasons
139
+ .slice(0, config.maximumListedDiagnostics)
140
+ .map((diagnostic) => ({
141
+ label: diagnostic.reason,
142
+ value: `${diagnostic.message} (${diagnostic.path})`,
143
+ })),
144
+ ...found.skills.map((skill) => ({
145
+ label: skill.name,
146
+ value: skill.location,
147
+ })),
148
+ ].slice(0, 24),
134
149
  options: found.skills.map((skill) => ({
135
150
  value: skill.name,
136
151
  label: skill.name,
137
- description: skill.location,
152
+ description: skill.description
153
+ .replace(/\s+/gu, " ")
154
+ .trim()
155
+ .slice(0, 100),
138
156
  })),
139
157
  }, {}, "name");
140
158
  }
@@ -2,32 +2,64 @@ import { defineClientEntry, } from "@fungi.computer/shiitake/client-entry";
2
2
  import { subagentsContract, } from "./contract.js";
3
3
  const presentation = {
4
4
  working: { label: "working", tone: "running", status: "info" },
5
- waiting: { label: "waiting on you", tone: "attention", status: "warning" },
5
+ waiting_on_agent: { label: "waiting", tone: "muted", status: "neutral" },
6
+ needs_you: { label: "needs you", tone: "attention", status: "warning" },
6
7
  idle: { label: "idle", tone: "settled", status: "success" },
7
8
  failed: { label: "failed", tone: "error", status: "error" },
8
9
  unavailable: { label: "unavailable", tone: "muted", status: "neutral" },
9
10
  };
11
+ /** "waiting on Shiitake": the Agent a calm child waits on, never "you". */
12
+ function label(agent) {
13
+ return agent.state === "waiting_on_agent"
14
+ ? `waiting on ${agent.waitingOn ?? "its parent"}`
15
+ : presentation[agent.state].label;
16
+ }
10
17
  function row(agent) {
11
18
  const shown = presentation[agent.state];
12
19
  const base = {
13
20
  id: agent.agentId,
14
- title: `${agent.name} · ${shown.label}`,
21
+ title: `${agent.name} · ${label(agent)}`,
15
22
  tone: shown.tone,
16
- status: { label: shown.label, tone: shown.status },
23
+ status: { label: label(agent), tone: shown.status },
17
24
  };
18
- const detailed = agent.attention === undefined ? base : { ...base, detail: agent.attention };
25
+ // The question itself, so a person can decide without opening the child.
26
+ const asked = agent.ask?.questions[0]?.question;
27
+ const text = asked ?? agent.attention;
28
+ const detailed = text === undefined ? base : { ...base, detail: text.slice(0, 200) };
19
29
  // An Agent created but never messaged has no conversation to open yet.
20
- return agent.sessionId === null
21
- ? { ...detailed, detail: agent.attention ?? "not messaged yet" }
22
- : {
23
- ...detailed,
24
- open: {
25
- params: [
26
- { key: "agent", value: agent.agentId },
27
- { key: "session", value: agent.sessionId },
28
- ],
29
- },
30
- };
30
+ if (agent.sessionId === null)
31
+ return { ...detailed, detail: agent.attention ?? "not messaged yet" };
32
+ const opened = {
33
+ ...detailed,
34
+ open: {
35
+ params: [
36
+ { key: "agent", value: agent.agentId },
37
+ { key: "session", value: agent.sessionId },
38
+ ],
39
+ },
40
+ };
41
+ if (agent.ask === undefined)
42
+ return opened;
43
+ const answer = {
44
+ id: "answerChild",
45
+ label: "Answer",
46
+ input: {
47
+ agentId: agent.agentId,
48
+ sessionId: agent.sessionId,
49
+ askId: agent.ask.askId,
50
+ },
51
+ };
52
+ // A question offers the one Answer action, calm or not: a person may always
53
+ // answer, and the first answer wins. Only a question that needs the person
54
+ // takes over the composer.
55
+ return {
56
+ ...opened,
57
+ actions: [
58
+ agent.state === "needs_you"
59
+ ? { ...answer, awaiting: "question" }
60
+ : answer,
61
+ ],
62
+ };
31
63
  }
32
64
  /**
33
65
  * One summary row while the footer is collapsed: how
@@ -36,12 +68,24 @@ function row(agent) {
36
68
  */
37
69
  function summary(view) {
38
70
  const count = (state) => view.agents.filter((agent) => agent.state === state).length;
39
- const counts = ["working", "waiting", "failed", "unavailable"]
40
- .map((state) => [state, count(state)])
41
- .filter(([, n]) => n > 0)
42
- .map(([state, n]) => `${String(n)} ${presentation[state].label}`);
71
+ const calm = view.agents.filter((agent) => agent.state === "waiting_on_agent");
72
+ // Calm children name who they wait on; only needs-you children are amber.
73
+ const waiting = [
74
+ ...new Set(calm.map((agent) => agent.waitingOn ?? "its parent")),
75
+ ].map((name) => `${String(calm.filter((agent) => (agent.waitingOn ?? "its parent") === name).length)} waiting on ${name}`);
76
+ const counts = [
77
+ ...["working"]
78
+ .map((state) => [state, count(state)])
79
+ .filter(([, n]) => n > 0)
80
+ .map(([state, n]) => `${String(n)} ${presentation[state].label}`),
81
+ ...waiting,
82
+ ...["needs_you", "failed", "unavailable"]
83
+ .map((state) => [state, count(state)])
84
+ .filter(([, n]) => n > 0)
85
+ .map(([state, n]) => `${String(n)} ${presentation[state].label}`),
86
+ ];
43
87
  const total = view.agents.length + view.more;
44
- const tone = count("waiting") > 0
88
+ const tone = count("needs_you") > 0
45
89
  ? "attention"
46
90
  : count("failed") > 0
47
91
  ? "error"