@junejs/server 0.1.2-dev.3 → 0.1.2-dev.7

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.
@@ -1,5 +1,5 @@
1
1
  import { Channel, ChannelFactory } from "@junejs/core/agent-config";
2
- import { InboundEvent, Model, Msg, SessionStore, Tool, TurnEvent } from "@junejs/core/agent-runtime";
2
+ import { InboundEvent, Model, Msg, ProactiveTrigger, SessionStore, Tool, TurnEvent } from "@junejs/core/agent-runtime";
3
3
  import { Resources } from "@junejs/core/resources";
4
4
  //#region src/agent-durable.d.ts
5
5
  type SqlStorageValue = ArrayBuffer | string | number | null;
@@ -30,11 +30,14 @@ declare class DoSessionStore implements SessionStore {
30
30
  private get sql();
31
31
  appendMessage(m: Msg): void;
32
32
  messages(): Msg[];
33
- hasUserTurn(turnId: string): boolean;
33
+ hasOpeningMessage(turnId: string): boolean;
34
34
  getStep(id: string): unknown | undefined;
35
35
  putStep(id: string, output: unknown): void;
36
+ delStep(id: string): void;
36
37
  getStatus(): string;
37
38
  setStatus(s: string): void;
39
+ getSessionKey(): string | undefined;
40
+ setSessionKey(key: string): void;
38
41
  tx<T>(fn: () => T): T;
39
42
  unwrap<H = unknown>(): H;
40
43
  }
@@ -48,22 +51,48 @@ type DoAgentDef = {
48
51
  env?: unknown;
49
52
  resources?: Resources;
50
53
  services?: unknown;
54
+ onTurnError?: (failure: {
55
+ turnId: string;
56
+ error: {
57
+ message: string;
58
+ };
59
+ }) => void | Promise<void>;
51
60
  };
52
61
  declare class AgentDurableObject {
53
62
  private session;
63
+ private sessionKey;
64
+ private readonly name;
65
+ private readonly store;
66
+ private readonly sink;
67
+ private readonly model;
68
+ private readonly tools;
69
+ private readonly channelInstructions?;
54
70
  private readonly resources;
55
71
  private readonly services;
56
72
  constructor(state: DurableObjectState, def: DoAgentDef);
73
+ private resolveSession;
57
74
  turn(input: {
58
75
  turnId?: string;
59
76
  userText: string;
60
77
  event?: InboundEvent;
78
+ trigger?: ProactiveTrigger;
79
+ session?: string;
61
80
  }): Promise<string>;
81
+ start(input: {
82
+ turnId?: string;
83
+ userText: string;
84
+ event?: InboundEvent;
85
+ trigger?: ProactiveTrigger;
86
+ session?: string;
87
+ }): Promise<{
88
+ turnId: string;
89
+ }>;
62
90
  transcript(): import("@junejs/core/agent-runtime").Turn[];
63
91
  fetch(req: Request): Promise<Response>;
64
92
  }
65
93
  declare function sseTurnFinalText(res: Response): Promise<string>;
66
94
  declare function sseTurnEvents(res: Response): AsyncIterable<TurnEvent>;
95
+ declare const SESSION_HEADER = "x-june-session";
67
96
  declare function durableFetch(namespace: DurableObjectNamespace, agent: string, session: string, req: Request): Promise<Response>;
68
97
  declare function durableAgentSurface(getNamespace: () => DurableObjectNamespace | undefined, opts: {
69
98
  agentName: string;
@@ -77,4 +106,4 @@ declare function durableChannelSurface(getNamespace: () => DurableObjectNamespac
77
106
  waitUntil?: (p: Promise<unknown>) => void;
78
107
  }): (req: Request) => Promise<Response | null>;
79
108
  //#endregion
80
- export { AgentDurableObject, DoAgentDef, DoSessionStore, DurableObjectNamespace, DurableObjectState, DurableObjectStub, DurableStorage, SqlStorage, SqlStorageCursor, SqlStorageValue, durableAgentSurface, durableChannelSurface, durableFetch, sseTurnEvents, sseTurnFinalText };
109
+ export { AgentDurableObject, DoAgentDef, DoSessionStore, DurableObjectNamespace, DurableObjectState, DurableObjectStub, DurableStorage, SESSION_HEADER, SqlStorage, SqlStorageCursor, SqlStorageValue, durableAgentSurface, durableChannelSurface, durableFetch, sseTurnEvents, sseTurnFinalText };
@@ -1,5 +1,5 @@
1
1
  import { channelDispatch, resolveChannel } from "@junejs/core/agent-config";
2
- import { AgentSession, withSystem } from "@junejs/core/agent-runtime";
2
+ import { AgentSession, ResumeAuthorizationError, withSystem } from "@junejs/core/agent-runtime";
3
3
  import { ensureScope, runInScope } from "@junejs/db";
4
4
  //#region src/agent-durable.ts
5
5
  var DoSessionStore = class {
@@ -20,8 +20,8 @@ var DoSessionStore = class {
20
20
  messages() {
21
21
  return this.sql.exec("SELECT body FROM agent_messages ORDER BY seq").toArray().map((r) => JSON.parse(r.body));
22
22
  }
23
- hasUserTurn(turnId) {
24
- return this.messages().some((m) => m.role === "user" && m.turnId === turnId);
23
+ hasOpeningMessage(turnId) {
24
+ return this.messages().some((m) => (m.role === "user" || m.role === "trigger") && m.turnId === turnId);
25
25
  }
26
26
  getStep(id) {
27
27
  const rows = this.sql.exec("SELECT output FROM agent_steps WHERE id = ?", id).toArray();
@@ -30,6 +30,9 @@ var DoSessionStore = class {
30
30
  putStep(id, output) {
31
31
  this.sql.exec("INSERT INTO agent_steps (id, output) VALUES (?, ?)", id, JSON.stringify(output));
32
32
  }
33
+ delStep(id) {
34
+ this.sql.exec("DELETE FROM agent_steps WHERE id = ?", id);
35
+ }
33
36
  getStatus() {
34
37
  const rows = this.sql.exec("SELECT v FROM agent_meta WHERE k = 'status'").toArray();
35
38
  return rows.length ? rows[0].v : "new";
@@ -37,6 +40,13 @@ var DoSessionStore = class {
37
40
  setStatus(s) {
38
41
  this.sql.exec("INSERT INTO agent_meta (k, v) VALUES ('status', ?) ON CONFLICT(k) DO UPDATE SET v = ?", s, s);
39
42
  }
43
+ getSessionKey() {
44
+ const rows = this.sql.exec("SELECT v FROM agent_meta WHERE k = 'session_key'").toArray();
45
+ return rows.length ? rows[0].v : void 0;
46
+ }
47
+ setSessionKey(key) {
48
+ this.sql.exec("INSERT INTO agent_meta (k, v) VALUES ('session_key', ?) ON CONFLICT(k) DO UPDATE SET v = ?", key, key);
49
+ }
40
50
  tx(fn) {
41
51
  return this.storage.transactionSync(fn);
42
52
  }
@@ -63,6 +73,13 @@ const crossDoUnsupported = { session() {
63
73
  } };
64
74
  var AgentDurableObject = class {
65
75
  session;
76
+ sessionKey;
77
+ name;
78
+ store;
79
+ sink;
80
+ model;
81
+ tools;
82
+ channelInstructions;
66
83
  resources;
67
84
  services;
68
85
  constructor(state, def) {
@@ -70,6 +87,26 @@ var AgentDurableObject = class {
70
87
  const model = def.instructions ? withSystem(def.model, def.instructions) : def.model;
71
88
  this.resources = def.resources ?? {};
72
89
  this.services = def.services;
90
+ const name = def.name ?? "agent";
91
+ const sink = new InProcEventSink();
92
+ sink.subscribe((e) => {
93
+ if (e.type !== "turn.failed") return;
94
+ const defaultLog = () => console.error(`[june] agent "${name}" turn ${e.turnId} failed: ${e.error.message}`);
95
+ if (def.onTurnError) try {
96
+ const out = def.onTurnError({
97
+ turnId: e.turnId,
98
+ error: e.error
99
+ });
100
+ if (out && typeof out.then === "function") out.then(void 0, (hookErr) => {
101
+ console.error(`[june] agent "${name}": onTurnError hook rejected:`, hookErr);
102
+ defaultLog();
103
+ });
104
+ return;
105
+ } catch (hookErr) {
106
+ console.error(`[june] agent "${name}": onTurnError hook threw:`, hookErr);
107
+ }
108
+ defaultLog();
109
+ });
73
110
  const channelTools = (def.channels ?? []).flatMap((c) => resolveChannel(c, def.env).tools?.() ?? []);
74
111
  const tools = [...def.tools];
75
112
  const seen = new Set(tools.map((t) => t.spec.name));
@@ -78,35 +115,98 @@ var AgentDurableObject = class {
78
115
  seen.add(t.spec.name);
79
116
  tools.push(t);
80
117
  }
81
- this.session = new AgentSession(def.name ?? "agent", "self", store, new InProcEventSink(), model, tools, crossDoUnsupported, def.channelInstructions);
118
+ this.name = name;
119
+ this.store = store;
120
+ this.sink = sink;
121
+ this.model = model;
122
+ this.tools = tools;
123
+ this.channelInstructions = def.channelInstructions;
124
+ }
125
+ resolveSession(key) {
126
+ if (key !== void 0) assertSessionKey(key);
127
+ if (this.session) {
128
+ if (key !== void 0 && this.sessionKey !== key) throw new Error(this.sessionKey === "self" ? `agent "${this.name}": request session "${key}" arrived, but this session is bound to the placeholder "self" (first used without a real key) — carry the key on every path (route through durableFetch, or pass turn({ session }) on the direct API)` : `agent "${this.name}": request session "${key}" does not match this object's session "${this.sessionKey}" — one DO is one session; address each session by its own key (durableFetch, or turn({ session }) on the direct API)`);
129
+ return this.session;
130
+ }
131
+ const stored = this.store.getSessionKey();
132
+ if (key !== void 0 && stored !== void 0 && key !== stored) throw new Error(`agent "${this.name}": request session "${key}" does not match this object's session "${stored}" — one DO is one session; address each session by its own key (durableFetch, or turn({ session }) on the direct API)`);
133
+ const resolved = key ?? stored ?? "self";
134
+ if (key !== void 0 && key !== "self" && stored === void 0) this.store.setSessionKey(key);
135
+ this.sessionKey = resolved;
136
+ this.session = new AgentSession(this.name, resolved, this.store, this.sink, this.model, this.tools, crossDoUnsupported, this.channelInstructions);
137
+ return this.session;
82
138
  }
83
139
  async turn(input) {
84
140
  await ensureScope();
141
+ const session = this.resolveSession(input.session);
142
+ return runInScope({
143
+ resources: this.resources,
144
+ services: this.services
145
+ }, () => session.turn(input));
146
+ }
147
+ async start(input) {
148
+ await ensureScope();
149
+ const session = this.resolveSession(input.session);
85
150
  return runInScope({
86
151
  resources: this.resources,
87
152
  services: this.services
88
- }, () => this.session.turn(input));
153
+ }, () => session.start(input));
89
154
  }
90
155
  transcript() {
91
- return this.session.transcript();
156
+ if (this.session) return this.session.transcript();
157
+ if (this.store.getSessionKey() !== void 0) return this.resolveSession().transcript();
158
+ return new AgentSession(this.name, "self", this.store, this.sink, this.model, this.tools, crossDoUnsupported, this.channelInstructions).transcript();
92
159
  }
93
160
  async fetch(req) {
94
161
  const url = new URL(req.url);
162
+ const key = req.headers.get("x-june-session") ?? void 0;
95
163
  if (req.method === "POST" && url.pathname.endsWith("/turn")) {
96
- const { userText, turnId, event } = await req.json();
164
+ const { userText, turnId, event, trigger } = await req.json();
97
165
  await ensureScope();
98
- const started = runInScope({
99
- resources: this.resources,
100
- services: this.services
101
- }, () => this.session.start({
102
- userText,
103
- turnId,
104
- event
105
- }));
106
- return new Response(sseTurnStream(this.session, started.turnId), { headers: SSE_HEADERS });
166
+ let session;
167
+ let started;
168
+ try {
169
+ session = this.resolveSession(key);
170
+ started = runInScope({
171
+ resources: this.resources,
172
+ services: this.services
173
+ }, () => session.start({
174
+ userText,
175
+ turnId,
176
+ event,
177
+ trigger
178
+ }));
179
+ } catch (err) {
180
+ return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
181
+ }
182
+ if (url.searchParams.get("detach") === "1") return Response.json({ turnId: started.turnId }, { status: 202 });
183
+ return new Response(sseTurnStream(session, started.turnId), { headers: SSE_HEADERS });
107
184
  }
108
- if (url.pathname.endsWith("/transcript")) return Response.json({ transcript: this.transcript() });
109
- return new Response("agent DO — POST /turn or GET /transcript", { status: 404 });
185
+ if (req.method === "POST" && url.pathname.endsWith("/resume")) {
186
+ const { turnId, inputId, input, by } = await req.json();
187
+ await ensureScope();
188
+ let session;
189
+ try {
190
+ session = this.resolveSession(key);
191
+ runInScope({
192
+ resources: this.resources,
193
+ services: this.services
194
+ }, () => session.resume(turnId, inputId, input, { by }));
195
+ } catch (err) {
196
+ const message = err instanceof Error ? err.message : String(err);
197
+ return Response.json({ error: message }, { status: err instanceof ResumeAuthorizationError ? 403 : 409 });
198
+ }
199
+ return new Response(sseTurnStream(session, turnId), { headers: SSE_HEADERS });
200
+ }
201
+ if (url.pathname.endsWith("/transcript")) {
202
+ if (key !== void 0) try {
203
+ return Response.json({ transcript: this.resolveSession(key).transcript() });
204
+ } catch (err) {
205
+ return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
206
+ }
207
+ return Response.json({ transcript: this.transcript() });
208
+ }
209
+ return new Response("agent DO — POST /turn, POST /resume, or GET /transcript", { status: 404 });
110
210
  }
111
211
  };
112
212
  const SSE_HEADERS = {
@@ -132,7 +232,7 @@ function sseTurnStream(session, turnId) {
132
232
  }, 2e4);
133
233
  unsub = session.observe((e) => {
134
234
  controller.enqueue(enc.encode(`data: ${JSON.stringify(e)}\n\n`));
135
- if (e.type === "turn.completed" || e.type === "turn.failed") {
235
+ if (e.type === "turn.completed" || e.type === "turn.failed" || e.type === "input.requested") {
136
236
  stop();
137
237
  controller.close();
138
238
  }
@@ -189,8 +289,16 @@ async function* sseTurnEvents(res) {
189
289
  if (done) break;
190
290
  }
191
291
  }
292
+ const SESSION_HEADER = "x-june-session";
293
+ const SESSION_KEY_RE = /^[\x20-\x7E]+$/;
294
+ function assertSessionKey(session) {
295
+ if (typeof session !== "string" || !SESSION_KEY_RE.test(session)) throw new Error(`invalid session key ${JSON.stringify(session)} — a session key must be a non-empty printable-ASCII string (it rides an HTTP header and the DO name)`);
296
+ }
192
297
  function durableFetch(namespace, agent, session, req) {
193
- return namespace.get(namespace.idFromName(`${agent}:${session}`)).fetch(req);
298
+ assertSessionKey(session);
299
+ const headers = new Headers(req.headers);
300
+ headers.set(SESSION_HEADER, session);
301
+ return namespace.get(namespace.idFromName(`${agent}:${session}`)).fetch(new Request(req, { headers }));
194
302
  }
195
303
  function durableAgentSurface(getNamespace, opts) {
196
304
  const chatPath = opts.chatPath ?? "/message";
@@ -200,6 +308,11 @@ function durableAgentSurface(getNamespace, opts) {
200
308
  const namespace = getNamespace();
201
309
  if (!namespace) return null;
202
310
  const { message, session } = await req.json();
311
+ if (session !== void 0) try {
312
+ assertSessionKey(session);
313
+ } catch (err) {
314
+ return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 400 });
315
+ }
203
316
  const res = await durableFetch(namespace, opts.agentName, session ?? "default", new Request("https://do/turn", {
204
317
  method: "POST",
205
318
  body: JSON.stringify({ userText: message })
@@ -228,6 +341,19 @@ function durableChannelSurface(getNamespace, opts) {
228
341
  body: serializeTurn(message, o)
229
342
  })));
230
343
  },
344
+ runDetached: async (message, o) => {
345
+ const namespace = getNamespace();
346
+ if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
347
+ const res = await durableFetch(namespace, opts.agentName, o?.session ?? "default", new Request("https://do/turn?detach=1", {
348
+ method: "POST",
349
+ body: serializeTurn(message, o)
350
+ }));
351
+ if (res.status !== 202) {
352
+ const detail = (await res.text()).slice(0, 200);
353
+ throw new Error(`runDetached: turn was not accepted (status ${res.status}): ${detail}`);
354
+ }
355
+ return await res.json();
356
+ },
231
357
  runStream: async function* (message, o) {
232
358
  const namespace = getNamespace();
233
359
  if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
@@ -235,14 +361,35 @@ function durableChannelSurface(getNamespace, opts) {
235
361
  method: "POST",
236
362
  body: serializeTurn(message, o)
237
363
  })));
364
+ },
365
+ resumeStream: async function* (o) {
366
+ const namespace = getNamespace();
367
+ if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
368
+ yield* sseTurnEvents(await durableFetch(namespace, opts.agentName, o.session ?? "default", new Request("https://do/resume", {
369
+ method: "POST",
370
+ body: serializeResume(o)
371
+ })));
238
372
  }
239
373
  });
240
374
  }
375
+ function serializeResume(o) {
376
+ try {
377
+ return JSON.stringify({
378
+ turnId: o.turnId,
379
+ inputId: o.inputId,
380
+ input: o.input,
381
+ by: o.by
382
+ });
383
+ } catch (err) {
384
+ throw new Error(`resumeStream: input is not JSON-serializable (${err.message}) — a resume answer must round-trip to the DO`);
385
+ }
386
+ }
241
387
  function serializeTurn(userText, o) {
242
388
  const payload = {
243
389
  userText,
244
390
  turnId: o?.turnId,
245
- event: o?.event
391
+ event: o?.event,
392
+ trigger: o?.trigger
246
393
  };
247
394
  try {
248
395
  return JSON.stringify(payload);
@@ -258,4 +405,4 @@ function serializeTurn(userText, o) {
258
405
  }
259
406
  }
260
407
  //#endregion
261
- export { AgentDurableObject, DoSessionStore, durableAgentSurface, durableChannelSurface, durableFetch, sseTurnEvents, sseTurnFinalText };
408
+ export { AgentDurableObject, DoSessionStore, SESSION_HEADER, durableAgentSurface, durableChannelSurface, durableFetch, sseTurnEvents, sseTurnFinalText };
@@ -20,8 +20,8 @@ var SqliteSessionStore = class {
20
20
  messages() {
21
21
  return this.db.query("SELECT body FROM agent_messages WHERE session_id = ? ORDER BY seq").all(this.sid).map((r) => JSON.parse(r.body));
22
22
  }
23
- hasUserTurn(turnId) {
24
- return this.messages().some((m) => m.role === "user" && m.turnId === turnId);
23
+ hasOpeningMessage(turnId) {
24
+ return this.messages().some((m) => (m.role === "user" || m.role === "trigger") && m.turnId === turnId);
25
25
  }
26
26
  getStep(id) {
27
27
  const r = this.db.query("SELECT output FROM agent_steps WHERE session_id = ? AND id = ?").get(this.sid, id);
@@ -30,6 +30,9 @@ var SqliteSessionStore = class {
30
30
  putStep(id, output) {
31
31
  this.db.query("INSERT INTO agent_steps (session_id, id, output) VALUES (?, ?, ?)").run(this.sid, id, JSON.stringify(output));
32
32
  }
33
+ delStep(id) {
34
+ this.db.query("DELETE FROM agent_steps WHERE session_id = ? AND id = ?").run(this.sid, id);
35
+ }
33
36
  getStatus() {
34
37
  return this.db.query("SELECT status FROM agent_sessions WHERE session_id = ?").get(this.sid)?.status ?? "new";
35
38
  }
@@ -100,8 +103,8 @@ var MemorySessionStore = class {
100
103
  messages() {
101
104
  return this.msgs.slice();
102
105
  }
103
- hasUserTurn(turnId) {
104
- return this.msgs.some((m) => m.role === "user" && m.turnId === turnId);
106
+ hasOpeningMessage(turnId) {
107
+ return this.msgs.some((m) => (m.role === "user" || m.role === "trigger") && m.turnId === turnId);
105
108
  }
106
109
  getStep(id) {
107
110
  return this.steps.has(id) ? this.steps.get(id) : void 0;
@@ -109,6 +112,9 @@ var MemorySessionStore = class {
109
112
  putStep(id, output) {
110
113
  this.steps.set(id, output);
111
114
  }
115
+ delStep(id) {
116
+ this.steps.delete(id);
117
+ }
112
118
  getStatus() {
113
119
  return this.status;
114
120
  }
@@ -156,7 +162,14 @@ function mountAgent(agent, runtime, opts = {}) {
156
162
  run: (message, o) => runtime.session(agent.name, o?.session ?? "default").turn({
157
163
  turnId: o?.turnId,
158
164
  userText: message,
159
- event: o?.event
165
+ event: o?.event,
166
+ trigger: o?.trigger
167
+ }),
168
+ runDetached: async (message, o) => runtime.session(agent.name, o?.session ?? "default").start({
169
+ turnId: o?.turnId,
170
+ userText: message,
171
+ event: o?.event,
172
+ trigger: o?.trigger
160
173
  })
161
174
  };
162
175
  const channels = channelFetch(agent, ctx);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junejs/server",
3
- "version": "0.1.2-dev.3",
3
+ "version": "0.1.2-dev.7",
4
4
  "description": "June host adapters + dev server. Host-coupled; depends on the pure @junejs/core contract layer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -125,7 +125,7 @@
125
125
  "build": "tsdown"
126
126
  },
127
127
  "dependencies": {
128
- "@junejs/core": ">=0.1.1-dev.5",
128
+ "@junejs/core": ">=0.1.1-dev.14",
129
129
  "@junejs/db": ">=0.0.34-dev.0",
130
130
  "@momiji-rs/sparkdown": "^0.0.4",
131
131
  "lightningcss": "^1.27.0",
@@ -17,6 +17,7 @@
17
17
 
18
18
  import {
19
19
  AgentSession,
20
+ ResumeAuthorizationError,
20
21
  withSystem,
21
22
  type EventSink,
22
23
  type TurnEvent,
@@ -25,6 +26,7 @@ import {
25
26
  type Runtime,
26
27
  type InboundEvent,
27
28
  type SessionStore,
29
+ type ProactiveTrigger,
28
30
  type Tool,
29
31
  } from "@junejs/core/agent-runtime";
30
32
  import type { Resources } from "@junejs/core/resources";
@@ -95,8 +97,8 @@ export class DoSessionStore implements SessionStore {
95
97
  messages(): Msg[] {
96
98
  return this.sql.exec<{ body: string }>("SELECT body FROM agent_messages ORDER BY seq").toArray().map((r) => JSON.parse(r.body));
97
99
  }
98
- hasUserTurn(turnId: string): boolean {
99
- return this.messages().some((m) => m.role === "user" && m.turnId === turnId);
100
+ hasOpeningMessage(turnId: string): boolean {
101
+ return this.messages().some((m) => (m.role === "user" || m.role === "trigger") && m.turnId === turnId);
100
102
  }
101
103
  getStep(id: string): unknown | undefined {
102
104
  const rows = this.sql.exec<{ output: string }>("SELECT output FROM agent_steps WHERE id = ?", id).toArray();
@@ -105,6 +107,9 @@ export class DoSessionStore implements SessionStore {
105
107
  putStep(id: string, output: unknown) {
106
108
  this.sql.exec("INSERT INTO agent_steps (id, output) VALUES (?, ?)", id, JSON.stringify(output));
107
109
  }
110
+ delStep(id: string) {
111
+ this.sql.exec("DELETE FROM agent_steps WHERE id = ?", id);
112
+ }
108
113
  getStatus(): string {
109
114
  const rows = this.sql.exec<{ v: string }>("SELECT v FROM agent_meta WHERE k = 'status'").toArray();
110
115
  return rows.length ? rows[0]!.v : "new";
@@ -112,6 +117,17 @@ export class DoSessionStore implements SessionStore {
112
117
  setStatus(s: string) {
113
118
  this.sql.exec("INSERT INTO agent_meta (k, v) VALUES ('status', ?) ON CONFLICT(k) DO UPDATE SET v = ?", s, s);
114
119
  }
120
+ // The externally-assigned session key (#75). A DO cannot read its own idFromName
121
+ // name, so the key arrives on the first routed request and is persisted here —
122
+ // surviving hibernation/eviction, where the constructor re-runs with no request
123
+ // in hand. Not part of core's SessionStore contract: only the DO seam needs it.
124
+ getSessionKey(): string | undefined {
125
+ const rows = this.sql.exec<{ v: string }>("SELECT v FROM agent_meta WHERE k = 'session_key'").toArray();
126
+ return rows.length ? rows[0]!.v : undefined;
127
+ }
128
+ setSessionKey(key: string) {
129
+ this.sql.exec("INSERT INTO agent_meta (k, v) VALUES ('session_key', ?) ON CONFLICT(k) DO UPDATE SET v = ?", key, key);
130
+ }
115
131
  tx<T>(fn: () => T): T {
116
132
  return this.storage.transactionSync(fn);
117
133
  }
@@ -166,6 +182,14 @@ export type DoAgentDef = {
166
182
  env?: unknown;
167
183
  resources?: Resources;
168
184
  services?: unknown;
185
+ // Called on every turn.failed for this session — the app's seam for routing turn
186
+ // failures to its own telemetry (Sentry, a ledger, …). Providing it REPLACES the
187
+ // default console.error; if the hook throws (or an async hook rejects), the default
188
+ // log fires anyway (both errors), so a failure is never silent. It happens to run
189
+ // inside the failed turn's request scope today (turn.failed is emitted mid-turn and
190
+ // ALS propagates), but that is NOT contract — don't rely on ambient db/services;
191
+ // close over what you need.
192
+ onTurnError?: (failure: { turnId: string; error: { message: string } }) => void | Promise<void>;
169
193
  };
170
194
 
171
195
  // The agent runtime INSIDE a Durable Object. A plain class (constructor takes the
@@ -183,7 +207,20 @@ export type DoAgentDef = {
183
207
  // fetch(req) { return this.agent.fetch(req); }
184
208
  // }
185
209
  export class AgentDurableObject {
186
- private session: AgentSession;
210
+ // The AgentSession is constructed LAZILY (#75): the DO is keyed externally by
211
+ // idFromName(`${agent}:${session}`) but cannot read its own name, so the true
212
+ // session key only exists on a routed request (the SESSION_HEADER durableFetch
213
+ // sets). First keyed request wins and persists the key (agent_meta) so it
214
+ // survives hibernation; key-less paths fall back to the persisted key, then
215
+ // "self" (the pre-#75 behavior, kept for backward compatibility).
216
+ private session: AgentSession | undefined;
217
+ private sessionKey: string | undefined;
218
+ private readonly name: string;
219
+ private readonly store: DoSessionStore;
220
+ private readonly sink: InProcEventSink;
221
+ private readonly model: Model;
222
+ private readonly tools: Tool[];
223
+ private readonly channelInstructions?: Record<string, string>;
187
224
  // Built from the DO's env in the constructor (this isolate), shared across turns —
188
225
  // env is stable per isolate, like bindWorkerResources memoizes per worker isolate.
189
226
  private readonly resources: Resources;
@@ -193,6 +230,40 @@ export class AgentDurableObject {
193
230
  const model = def.instructions ? withSystem(def.model, def.instructions) : def.model;
194
231
  this.resources = def.resources ?? {};
195
232
  this.services = def.services;
233
+ const name = def.name ?? "agent";
234
+ // Failure observability (#76): a turn that dies after the fast-ACK has no other
235
+ // observable surface on the edge — the webhook already 200'd and runBackground
236
+ // swallows the rejection unless the channel wired onError. Subscribing HERE (the
237
+ // session sink) covers every turn path — turn(), /turn, /resume — with one seam.
238
+ // Default: console.error, which wrangler tail surfaces. An app onTurnError takes
239
+ // over reporting; if IT throws, fall back to the default so nothing goes silent.
240
+ // (InProcEventSink already guards emit against a throwing subscriber.)
241
+ const sink = new InProcEventSink();
242
+ sink.subscribe((e) => {
243
+ if (e.type !== "turn.failed") return;
244
+ const defaultLog = () => console.error(`[june] agent "${name}" turn ${e.turnId} failed: ${e.error.message}`);
245
+ if (def.onTurnError) {
246
+ try {
247
+ const out = def.onTurnError({ turnId: e.turnId, error: e.error });
248
+ // An async hook (e.g. `await sendToSentry(...)`) types as void but returns a
249
+ // Promise — a rejection there must hit the same fallback as a sync throw, or
250
+ // the failure goes silent again (plus an unhandled rejection). No waitUntil
251
+ // keeper is needed here: unlike a Worker, a DO stays active while it has
252
+ // pending work/IO, and DurableObjectState.waitUntil is documented as a no-op —
253
+ // this .then subscription is what retains the promise.
254
+ if (out && typeof (out as Promise<void>).then === "function") {
255
+ (out as Promise<void>).then(undefined, (hookErr) => {
256
+ console.error(`[june] agent "${name}": onTurnError hook rejected:`, hookErr);
257
+ defaultLog();
258
+ });
259
+ }
260
+ return;
261
+ } catch (hookErr) {
262
+ console.error(`[june] agent "${name}": onTurnError hook threw:`, hookErr);
263
+ }
264
+ }
265
+ defaultLog();
266
+ });
196
267
  // Merge the mounted channels' capability tools (built here from this DO's env, since a
197
268
  // tool's `run` closure can't cross the RPC). The cross-channel source gate on each tool
198
269
  // keeps a Slack tool inert during a Crisp turn, so merging all of them is safe.
@@ -204,7 +275,49 @@ export class AgentDurableObject {
204
275
  seen.add(t.spec.name);
205
276
  tools.push(t);
206
277
  }
207
- this.session = new AgentSession(def.name ?? "agent", "self", store, new InProcEventSink(), model, tools, crossDoUnsupported, def.channelInstructions);
278
+ this.name = name;
279
+ this.store = store;
280
+ this.sink = sink;
281
+ this.model = model;
282
+ this.tools = tools;
283
+ this.channelInstructions = def.channelInstructions;
284
+ }
285
+ // Resolve THE session for this DO: explicit key (from a routed request) → persisted
286
+ // key (a prior life learned it) → "self". A key that contradicts the persisted or
287
+ // live identity is a mis-route (or a key-less path was used first) — fail loudly:
288
+ // silently proceeding is exactly the per-conversation data corruption #75 hit.
289
+ private resolveSession(key?: string): AgentSession {
290
+ // Every entry point shares the session-key contract (turn({ session }) and hand-rolled
291
+ // SESSION_HEADER values included, not just durableFetch): an invalid key persisted here
292
+ // would bind an identity no routed request could ever address — an orphaned session.
293
+ if (key !== undefined) assertSessionKey(key);
294
+ // Live fast path — no storage read once the session exists: this method is the only
295
+ // writer of the persisted key, so within one life it cannot diverge from the live one.
296
+ if (this.session) {
297
+ if (key !== undefined && this.sessionKey !== key) {
298
+ throw new Error(
299
+ this.sessionKey === "self"
300
+ ? `agent "${this.name}": request session "${key}" arrived, but this session is bound to the placeholder "self" (first used without a real key) — carry the key on every path (route through durableFetch, or pass turn({ session }) on the direct API)`
301
+ : `agent "${this.name}": request session "${key}" does not match this object's session "${this.sessionKey}" — one DO is one session; address each session by its own key (durableFetch, or turn({ session }) on the direct API)`,
302
+ );
303
+ }
304
+ return this.session;
305
+ }
306
+ const stored = this.store.getSessionKey();
307
+ if (key !== undefined && stored !== undefined && key !== stored) {
308
+ throw new Error(`agent "${this.name}": request session "${key}" does not match this object's session "${stored}" — one DO is one session; address each session by its own key (durableFetch, or turn({ session }) on the direct API)`);
309
+ }
310
+ const resolved = key ?? stored ?? "self";
311
+ // "self" is deliberately NOT persisted — even when a caller passes it EXPLICITLY: it
312
+ // is a placeholder ("no identity externally assigned yet"), not an identity. Within
313
+ // one life a keyed request after a "self" binding is refused above — two concurrently
314
+ // active paths disagreeing is a live bug. But across eviction a keyed request ADOPTS
315
+ // the legacy transcript: that asymmetry IS the migration path from pre-keyed
316
+ // deployments, where a persisted "self" would 409 every keyed request forever.
317
+ if (key !== undefined && key !== "self" && stored === undefined) this.store.setSessionKey(key);
318
+ this.sessionKey = resolved;
319
+ this.session = new AgentSession(this.name, resolved, this.store, this.sink, this.model, this.tools, crossDoUnsupported, this.channelInstructions);
320
+ return this.session;
208
321
  }
209
322
  // Run the whole turn inside a request scope seeded from this DO's env, so ambient
210
323
  // `db`/`kv`/`blob` and `currentServices()` resolve inside a tool exactly as in a
@@ -213,29 +326,91 @@ export class AgentDurableObject {
213
326
  // Juno's batch-loader registry) can't leak across turns on a long-lived DO.
214
327
  // ensureScope() lazily wires node:async_hooks (workerd via nodejs_compat), as the
215
328
  // pipeline does; without it runInScope is a pass-through and ambient reads throw.
216
- async turn(input: { turnId?: string; userText: string; event?: InboundEvent }): Promise<string> {
329
+ async turn(input: { turnId?: string; userText: string; event?: InboundEvent; trigger?: ProactiveTrigger; session?: string }): Promise<string> {
330
+ await ensureScope();
331
+ const session = this.resolveSession(input.session);
332
+ return runInScope({ resources: this.resources, services: this.services }, () => session.turn(input));
333
+ }
334
+ // Fire-and-forget for custom shells (#77): resolves once the turn is durably ACCEPTED;
335
+ // it then runs under the DO's own lifetime (a DO stays alive while it has pending
336
+ // work), not the caller's. No live consumer sees the result — failures surface via
337
+ // the default turn-failure log / onTurnError (#76).
338
+ async start(input: { turnId?: string; userText: string; event?: InboundEvent; trigger?: ProactiveTrigger; session?: string }): Promise<{ turnId: string }> {
217
339
  await ensureScope();
218
- return runInScope({ resources: this.resources, services: this.services }, () => this.session.turn(input));
340
+ const session = this.resolveSession(input.session);
341
+ return runInScope({ resources: this.resources, services: this.services }, () => session.start(input));
219
342
  }
343
+ // Read-only: folds the durable log. When an identity exists (live, or persisted from a
344
+ // prior life) the session resolves and caches like any other path. Only a read on a
345
+ // NEVER-keyed object stays non-committal — caching there would bake "self" in as the
346
+ // identity and 409 a later keyed turn — so it folds through an ephemeral AgentSession.
220
347
  transcript() {
221
- return this.session.transcript();
348
+ if (this.session) return this.session.transcript();
349
+ if (this.store.getSessionKey() !== undefined) return this.resolveSession().transcript();
350
+ return new AgentSession(this.name, "self", this.store, this.sink, this.model, this.tools, crossDoUnsupported, this.channelInstructions).transcript();
222
351
  }
223
352
  // Default HTTP surface: POST …/turn STREAMS the turn's TurnEvents as SSE (start the
224
353
  // turn in-scope, then stream from the session sink); GET …/transcript reads the log.
225
354
  // The app can call turn()/transcript() directly instead (turn() awaits the final text).
226
355
  async fetch(req: Request): Promise<Response> {
227
356
  const url = new URL(req.url);
357
+ // The session key durableFetch stamped on the routed request (#75); absent on a
358
+ // hand-rolled fetch → the pre-#75 "self" fallback keeps old callers working.
359
+ const key = req.headers.get(SESSION_HEADER) ?? undefined;
228
360
  if (req.method === "POST" && url.pathname.endsWith("/turn")) {
229
- const { userText, turnId, event } = (await req.json()) as { userText: string; turnId?: string; event?: InboundEvent };
361
+ const { userText, turnId, event, trigger } = (await req.json()) as { userText: string; turnId?: string; event?: InboundEvent; trigger?: ProactiveTrigger };
230
362
  await ensureScope();
231
363
  // start() schedules the turn on the chain WITHIN the scope, so it runs with ambient
232
364
  // db/services (ALS propagates to the .then continuation registered here); subscribing
233
365
  // happens synchronously right after, before any event can emit.
234
- const started = runInScope({ resources: this.resources, services: this.services }, () => this.session.start({ userText, turnId, event }));
235
- return new Response(sseTurnStream(this.session, started.turnId), { headers: SSE_HEADERS });
366
+ let session: AgentSession;
367
+ let started: { turnId: string };
368
+ try {
369
+ session = this.resolveSession(key);
370
+ started = runInScope({ resources: this.resources, services: this.services }, () => session.start({ userText, turnId, event, trigger }));
371
+ } catch (err) {
372
+ // e.g. the session is suspended awaiting input, or the key mis-matches this
373
+ // object's identity — a client-resolvable conflict, not a crash
374
+ return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
375
+ }
376
+ // DETACHED (#77): the turn is accepted — 202 now, no stream. It keeps running under
377
+ // this DO's lifetime (alive while work is pending), so its duration is no longer
378
+ // bounded by however long the CALLER can hold a connection (the edge waitUntil
379
+ // ceiling). Nobody consumes the result; failures surface via the #76 log/hook.
380
+ if (url.searchParams.get("detach") === "1") return Response.json({ turnId: started.turnId }, { status: 202 });
381
+ return new Response(sseTurnStream(session, started.turnId), { headers: SSE_HEADERS });
382
+ }
383
+ // Provide the input a suspended turn is waiting on and stream its continuation as SSE.
384
+ // NOTE: this default surface passes `by` straight from the body — the engine treats it as a
385
+ // VERIFIED identity, so the app must authenticate it upstream (e.g. take the user id from a
386
+ // signature-checked Slack interaction payload), never expose this endpoint raw to clients.
387
+ if (req.method === "POST" && url.pathname.endsWith("/resume")) {
388
+ const { turnId, inputId, input, by } = (await req.json()) as { turnId: string; inputId: string; input: unknown; by?: string };
389
+ await ensureScope();
390
+ let session: AgentSession;
391
+ try {
392
+ session = this.resolveSession(key);
393
+ runInScope({ resources: this.resources, services: this.services }, () => session.resume(turnId, inputId, input, { by }));
394
+ } catch (err) {
395
+ const message = err instanceof Error ? err.message : String(err);
396
+ // 403: the resumer may not answer; 409: not suspended / wrong turn / wrong input id / key mismatch
397
+ return Response.json({ error: message }, { status: err instanceof ResumeAuthorizationError ? 403 : 409 });
398
+ }
399
+ return new Response(sseTurnStream(session, turnId), { headers: SSE_HEADERS });
400
+ }
401
+ if (url.pathname.endsWith("/transcript")) {
402
+ // A keyed read still conflict-checks (and may commit the key — it is authentic,
403
+ // idFromName routed it here); a key-less read stays non-committal via transcript().
404
+ if (key !== undefined) {
405
+ try {
406
+ return Response.json({ transcript: this.resolveSession(key).transcript() });
407
+ } catch (err) {
408
+ return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
409
+ }
410
+ }
411
+ return Response.json({ transcript: this.transcript() });
236
412
  }
237
- if (url.pathname.endsWith("/transcript")) return Response.json({ transcript: this.transcript() });
238
- return new Response("agent DO — POST /turn or GET /transcript", { status: 404 });
413
+ return new Response("agent DO — POST /turn, POST /resume, or GET /transcript", { status: 404 });
239
414
  }
240
415
  }
241
416
 
@@ -263,7 +438,9 @@ function sseTurnStream(session: AgentSession, turnId: string): ReadableStream<Ui
263
438
  }, 20_000);
264
439
  unsub = session.observe((e) => {
265
440
  controller.enqueue(enc.encode(`data: ${JSON.stringify(e)}\n\n`));
266
- if (e.type === "turn.completed" || e.type === "turn.failed") { stop(); controller.close(); }
441
+ // terminal for the STREAM: completed, failed, OR suspended (input.requested → the turn
442
+ // parked; the stream ends and a later /resume opens a fresh continuation stream).
443
+ if (e.type === "turn.completed" || e.type === "turn.failed" || e.type === "input.requested") { stop(); controller.close(); }
267
444
  }, { turnId });
268
445
  },
269
446
  cancel() { stop(); },
@@ -323,10 +500,31 @@ export async function* sseTurnEvents(res: Response): AsyncIterable<TurnEvent> {
323
500
  }
324
501
  }
325
502
 
503
+ // The header durableFetch stamps the session key on (#75). A DO cannot read its own
504
+ // idFromName name, so this is how the externally-assigned session identity crosses
505
+ // into the object — exported so a custom shell that bypasses durableFetch can set it.
506
+ export const SESSION_HEADER = "x-june-session";
507
+
508
+ // A session key rides an HTTP header (SESSION_HEADER) and the DO name (idFromName) —
509
+ // reject values that can't: CR/LF would be header injection, and Headers.set throws a
510
+ // bare TypeError on control/non-ASCII bytes, turning a bad client value into an opaque
511
+ // 500 deep inside durableFetch. Non-empty printable ASCII is the contract.
512
+ const SESSION_KEY_RE = /^[\x20-\x7E]+$/;
513
+ function assertSessionKey(session: string) {
514
+ if (typeof session !== "string" || !SESSION_KEY_RE.test(session)) {
515
+ throw new Error(`invalid session key ${JSON.stringify(session)} — a session key must be a non-empty printable-ASCII string (it rides an HTTP header and the DO name)`);
516
+ }
517
+ }
518
+
326
519
  // Worker-side routing: address a session's DO by (agent, session) and forward the
327
- // request to it. `env.AGENT` is the DO namespace binding.
520
+ // request to it. `env.AGENT` is the DO namespace binding. The session key rides a
521
+ // header (not the body): one seam covers /turn, /resume, and /transcript without
522
+ // touching any body contract, and an older DO simply ignores it.
328
523
  export function durableFetch(namespace: DurableObjectNamespace, agent: string, session: string, req: Request): Promise<Response> {
329
- return namespace.get(namespace.idFromName(`${agent}:${session}`)).fetch(req);
524
+ assertSessionKey(session);
525
+ const headers = new Headers(req.headers);
526
+ headers.set(SESSION_HEADER, session);
527
+ return namespace.get(namespace.idFromName(`${agent}:${session}`)).fetch(new Request(req, { headers }));
330
528
  }
331
529
 
332
530
  // The EDGE agent surface for June's router: route the chat endpoint to the
@@ -348,6 +546,15 @@ export function durableAgentSurface(
348
546
  const namespace = getNamespace();
349
547
  if (!namespace) return null; // no DO binding → not mounted here
350
548
  const { message, session } = (await req.json()) as { message: string; session?: string };
549
+ // `session` is CLIENT input here — an un-headerable value must be a clear 400, not
550
+ // the opaque 500 assertSessionKey would become this deep in the worker.
551
+ if (session !== undefined) {
552
+ try {
553
+ assertSessionKey(session);
554
+ } catch (err) {
555
+ return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 400 });
556
+ }
557
+ }
351
558
  // Forward to the session's DO on its /turn contract — which now STREAMS SSE. A client
352
559
  // that asks for the stream (Accept: text/event-stream) gets live TurnEvents piped
353
560
  // through; otherwise collapse to the final { text } (the prior JSON contract).
@@ -421,6 +628,25 @@ export function durableChannelSurface(
421
628
  // /turn streams SSE; the simple path collapses it to the final text.
422
629
  return sseTurnFinalText(res);
423
630
  },
631
+ // FIRE-AND-FORGET (#77): POST /turn?detach=1 — the DO 202s once the turn is accepted
632
+ // and runs it under its OWN lifetime. This is how a shadow/observe hook (onEvent →
633
+ // assessment turn, reply dropped) escapes the edge waitUntil ceiling that killed
634
+ // 24–38s turns in production: nothing holds the edge open while the turn runs.
635
+ runDetached: async (message, o) => {
636
+ const namespace = getNamespace();
637
+ if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
638
+ const res = await durableFetch(
639
+ namespace,
640
+ opts.agentName,
641
+ o?.session ?? "default",
642
+ new Request("https://do/turn?detach=1", { method: "POST", body: serializeTurn(message, o) }),
643
+ );
644
+ if (res.status !== 202) {
645
+ const detail = (await res.text()).slice(0, 200);
646
+ throw new Error(`runDetached: turn was not accepted (status ${res.status}): ${detail}`);
647
+ }
648
+ return (await res.json()) as { turnId: string };
649
+ },
424
650
  // The LIVE path: hand the channel the TurnEvent stream so it can render as the turn runs.
425
651
  runStream: async function* (message, o) {
426
652
  const namespace = getNamespace();
@@ -433,6 +659,18 @@ export function durableChannelSurface(
433
659
  );
434
660
  yield* sseTurnEvents(res);
435
661
  },
662
+ // Resume a parked turn on its session's DO and stream the continuation.
663
+ resumeStream: async function* (o) {
664
+ const namespace = getNamespace();
665
+ if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
666
+ const res = await durableFetch(
667
+ namespace,
668
+ opts.agentName,
669
+ o.session ?? "default",
670
+ new Request("https://do/resume", { method: "POST", body: serializeResume(o) }),
671
+ );
672
+ yield* sseTurnEvents(res);
673
+ },
436
674
  };
437
675
  return channelDispatch(resolved, ctx);
438
676
  }
@@ -443,8 +681,22 @@ export function durableChannelSurface(
443
681
  // run the turn, so on a serialization failure we drop it rather than let an
444
682
  // unserializable payload take down turn forwarding entirely. (raw is optional on
445
683
  // InboundEvent precisely because it may not survive this boundary.)
446
- function serializeTurn(userText: string, o?: { turnId?: string; event?: InboundEvent }): string {
447
- const payload = { userText, turnId: o?.turnId, event: o?.event };
684
+ // Serialize a /resume RPC body. `input` is `unknown` — the human's answer — so a
685
+ // (third-party) host could hand us something JSON.stringify chokes on (BigInt, circular).
686
+ // Unlike serializeTurn's `raw`, `input` is essential: silently dropping it would resume the
687
+ // turn with the wrong answer. So fail loudly with a clear message rather than corrupt the resume.
688
+ function serializeResume(o: { turnId: string; inputId: string; input: unknown; by?: string }): string {
689
+ try {
690
+ return JSON.stringify({ turnId: o.turnId, inputId: o.inputId, input: o.input, by: o.by });
691
+ } catch (err) {
692
+ throw new Error(`resumeStream: input is not JSON-serializable (${(err as Error).message}) — a resume answer must round-trip to the DO`);
693
+ }
694
+ }
695
+
696
+ // `trigger` is ProactiveTrigger — plain strings by construction — so only event.raw can make
697
+ // the stringify throw; the fallback that strips it stays sufficient.
698
+ function serializeTurn(userText: string, o?: { turnId?: string; event?: InboundEvent; trigger?: ProactiveTrigger }): string {
699
+ const payload = { userText, turnId: o?.turnId, event: o?.event, trigger: o?.trigger };
448
700
  try {
449
701
  return JSON.stringify(payload);
450
702
  } catch {
@@ -46,8 +46,8 @@ class SqliteSessionStore implements SessionStore {
46
46
  return (this.db.query("SELECT body FROM agent_messages WHERE session_id = ? ORDER BY seq").all(this.sid) as { body: string }[])
47
47
  .map((r) => JSON.parse(r.body));
48
48
  }
49
- hasUserTurn(turnId: string): boolean {
50
- return this.messages().some((m) => m.role === "user" && m.turnId === turnId);
49
+ hasOpeningMessage(turnId: string): boolean {
50
+ return this.messages().some((m) => (m.role === "user" || m.role === "trigger") && m.turnId === turnId);
51
51
  }
52
52
  getStep(id: string): unknown | undefined {
53
53
  const r = this.db.query("SELECT output FROM agent_steps WHERE session_id = ? AND id = ?").get(this.sid, id) as { output: string } | undefined;
@@ -56,6 +56,9 @@ class SqliteSessionStore implements SessionStore {
56
56
  putStep(id: string, output: unknown) {
57
57
  this.db.query("INSERT INTO agent_steps (session_id, id, output) VALUES (?, ?, ?)").run(this.sid, id, JSON.stringify(output));
58
58
  }
59
+ delStep(id: string) {
60
+ this.db.query("DELETE FROM agent_steps WHERE session_id = ? AND id = ?").run(this.sid, id);
61
+ }
59
62
  getStatus(): string {
60
63
  return (this.db.query("SELECT status FROM agent_sessions WHERE session_id = ?").get(this.sid) as { status: string } | undefined)?.status ?? "new";
61
64
  }
@@ -136,9 +139,10 @@ class MemorySessionStore implements SessionStore {
136
139
  private status = "new";
137
140
  appendMessage(m: Msg) { this.msgs.push(m); }
138
141
  messages(): Msg[] { return this.msgs.slice(); }
139
- hasUserTurn(turnId: string): boolean { return this.msgs.some((m) => m.role === "user" && m.turnId === turnId); }
142
+ hasOpeningMessage(turnId: string): boolean { return this.msgs.some((m) => (m.role === "user" || m.role === "trigger") && m.turnId === turnId); }
140
143
  getStep(id: string): unknown | undefined { return this.steps.has(id) ? this.steps.get(id) : undefined; }
141
144
  putStep(id: string, output: unknown) { this.steps.set(id, output); }
145
+ delStep(id: string) { this.steps.delete(id); }
142
146
  getStatus(): string { return this.status; }
143
147
  setStatus(s: string) { this.status = s; }
144
148
  tx<T>(fn: () => T): T { return fn(); } // no rollback: an in-memory store is not a durability tier
@@ -209,7 +213,12 @@ export function mountAgent(
209
213
  agent,
210
214
  services: opts.services, // same DI bag reachable from channel hooks (parity with durableChannelSurface)
211
215
  run: (message, o) =>
212
- runtime.session(agent.name, o?.session ?? "default").turn({ turnId: o?.turnId, userText: message, event: o?.event }),
216
+ runtime.session(agent.name, o?.session ?? "default").turn({ turnId: o?.turnId, userText: message, event: o?.event, trigger: o?.trigger }),
217
+ // FIRE-AND-FORGET (#77): start() without awaiting the result. Native has no waitUntil
218
+ // ceiling (Node keeps floating promises alive), but the seam is the same so a channel
219
+ // written against ctx.runDetached behaves identically on both targets.
220
+ runDetached: async (message, o) =>
221
+ runtime.session(agent.name, o?.session ?? "default").start({ turnId: o?.turnId, userText: message, event: o?.event, trigger: o?.trigger }),
213
222
  };
214
223
  const channels = channelFetch(agent, ctx);
215
224
  const surface = async (req: Request): Promise<Response | null> => {