@agentchatme/agent-core 0.0.13131 → 0.0.1313111

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/dist/index.d.ts CHANGED
@@ -3,13 +3,13 @@ import { z } from 'zod';
3
3
  /** Low-cardinality identity attached to every coding-agent API operation. */
4
4
  declare const CODING_AGENTS_CLIENT_IDENTITY: {
5
5
  readonly name: "coding_agents";
6
- readonly version: "0.0.13131";
6
+ readonly version: "0.0.1313111";
7
7
  };
8
8
  /** Headers for raw HTTP and WebSocket transports that bypass the SDK. */
9
9
  declare const CODING_AGENTS_CLIENT_HEADERS: Readonly<Record<string, string>>;
10
10
 
11
11
  /** Published package version, kept in lockstep with package.json by tests. */
12
- declare const VERSION = "0.0.13131";
12
+ declare const VERSION = "0.0.1313111";
13
13
 
14
14
  declare const SyncRowSchema: z.ZodObject<{
15
15
  id: z.ZodString;
@@ -193,14 +193,17 @@ declare const StateSchema: z.ZodObject<{
193
193
  continuations: z.ZodNumber;
194
194
  updated_at: z.ZodString;
195
195
  pending_ack: z.ZodOptional<z.ZodString>;
196
+ pending_ack_requires_continuation: z.ZodOptional<z.ZodBoolean>;
196
197
  }, "strip", z.ZodTypeAny, {
197
198
  continuations: number;
198
199
  updated_at: string;
199
200
  pending_ack?: string | undefined;
201
+ pending_ack_requires_continuation?: boolean | undefined;
200
202
  }, {
201
203
  continuations: number;
202
204
  updated_at: string;
203
205
  pending_ack?: string | undefined;
206
+ pending_ack_requires_continuation?: boolean | undefined;
204
207
  }>>>;
205
208
  last_offer_at: z.ZodOptional<z.ZodString>;
206
209
  offer_declined_at: z.ZodOptional<z.ZodString>;
@@ -209,6 +212,7 @@ declare const StateSchema: z.ZodObject<{
209
212
  continuations: number;
210
213
  updated_at: string;
211
214
  pending_ack?: string | undefined;
215
+ pending_ack_requires_continuation?: boolean | undefined;
212
216
  }>;
213
217
  last_offer_at?: string | undefined;
214
218
  offer_declined_at?: string | undefined;
@@ -217,6 +221,7 @@ declare const StateSchema: z.ZodObject<{
217
221
  continuations: number;
218
222
  updated_at: string;
219
223
  pending_ack?: string | undefined;
224
+ pending_ack_requires_continuation?: boolean | undefined;
220
225
  }> | undefined;
221
226
  last_offer_at?: string | undefined;
222
227
  offer_declined_at?: string | undefined;
@@ -232,8 +237,8 @@ declare function recordContinuation(home: string, sessionKey: string, now?: Date
232
237
  * should be allowed to pick messages up again.
233
238
  */
234
239
  declare function resetSession(home: string, sessionKey: string): void;
235
- declare function setPendingAck(home: string, sessionKey: string, cursor: string, now?: Date): void;
236
- /** Read-and-clear the pending cursor for a session (user-prompt hook). */
240
+ declare function setPendingAck(home: string, sessionKey: string, cursor: string, now?: Date, requiresContinuation?: boolean): void;
241
+ /** Read-and-clear the pending cursor for a session (completed-turn boundary). */
237
242
  declare function takePendingAck(home: string, sessionKey: string, now?: Date): string | null;
238
243
  declare function shouldOfferRegistration(home: string, now?: Date): boolean;
239
244
  declare function recordRegistrationOffer(home: string, now?: Date): void;
@@ -309,8 +314,9 @@ declare function formatStopPickup(handle: string | null, rows: SyncRow[]): strin
309
314
  * Injected at session start when always-on was set up but the daemon isn't
310
315
  * beating (its heartbeat is stale — see alwaysOnHealth). Written in the FIRST
311
316
  * person because the agent relays it to its user, and deliberately careful not
312
- * to imply loss: messages that arrive while away queue for the next session,
313
- * they don't vanish. The one-line fix is inline so the agent can act on it.
317
+ * to imply that stored messages disappear: they remain in conversation
318
+ * history and their delivery envelopes queue within the normal retention
319
+ * window. The one-line fix is inline so the agent can act on it.
314
320
  */
315
321
  declare function formatAlwaysOnDown(copy: HostCopy): string;
316
322
  /**
@@ -402,6 +408,11 @@ interface HookInput {
402
408
  /** Claude Code SessionStart source: startup | resume | clear | compact | fork.
403
409
  * Undefined on other hosts/events. */
404
410
  source: string | undefined;
411
+ /**
412
+ * Whether this Stop belongs to the continuation created by a prior Stop
413
+ * decision. Both harnesses expose this as `stop_hook_active`.
414
+ */
415
+ stopHookActive?: boolean;
405
416
  }
406
417
  declare function readHookInput(stream?: NodeJS.ReadStream): Promise<HookInput>;
407
418
 
@@ -415,23 +426,31 @@ interface SessionStartResult {
415
426
  /** Text to inject into the session, or null for "say nothing". */
416
427
  context: string | null;
417
428
  }
429
+ interface UserPromptResult {
430
+ /** Text to add to this prompt, or null when the inbox has nothing new. */
431
+ context: string | null;
432
+ /**
433
+ * Persist the surfaced cursor locally. Call only after the host has accepted
434
+ * `context`; the following Stop is the first boundary allowed to ACK it.
435
+ */
436
+ stage: () => void;
437
+ }
418
438
  interface StopResult {
419
439
  /** Text to continue the session with, or null to let it stop. */
420
440
  reason: string | null;
421
441
  /**
422
- * Commit the surfaced batch as delivered. Call this ONLY after the host has
423
- * actually been given `reason` (invariant 3). Safe to call when `reason` is
424
- * null — it is a no-op.
442
+ * Persist the surfaced cursor locally. Call this ONLY after the host has
443
+ * actually been given `reason`. The following Stop commits it remotely.
425
444
  */
426
- commit: () => Promise<void>;
445
+ stage: () => void;
427
446
  }
428
447
  declare function hooksDisabled(): boolean;
429
448
  declare function sessionStart(ctx: HookContext, input: HookInput): Promise<SessionStartResult>;
430
449
  /**
431
- * A prompt is running, so the session is real commit the digest batch that
432
- * session-start injected. Silent in every outcome.
450
+ * A prompt is about to run. Claim and inject the inbox at this real turn
451
+ * boundary, then stage its cursor only after the host accepts our output.
433
452
  */
434
- declare function userPrompt(ctx: HookContext, input: HookInput): Promise<void>;
453
+ declare function userPrompt(ctx: HookContext, input: HookInput): Promise<UserPromptResult>;
435
454
  declare function stop(ctx: HookContext, input: HookInput): Promise<StopResult>;
436
455
  /** A host session is closing. Release only its own foreground lease. */
437
456
  declare function sessionEnd(ctx: HookContext, input: HookInput): Promise<void>;
@@ -443,6 +462,7 @@ declare function sessionEnd(ctx: HookContext, input: HookInput): Promise<void>;
443
462
  */
444
463
  interface HookDialect {
445
464
  sessionStartOutput(context: string): Record<string, unknown>;
465
+ userPromptOutput(context: string): Record<string, unknown>;
446
466
  stopOutput(reason: string): Record<string, unknown>;
447
467
  printJson(payload: Record<string, unknown>): void;
448
468
  }
@@ -504,9 +524,9 @@ interface HostProfile {
504
524
  anchorLabel?: string;
505
525
  /**
506
526
  * Whether this host is wired up enough for an anchor to mean anything.
507
- * A host that must edit config files first (Codex) uses this so it never
508
- * writes an identity block announcing a phone number with nothing to answer
509
- * it. Hosts wired by their own installer (a Claude Code plugin) omit it.
527
+ * An integration uses this so it never writes an identity block announcing
528
+ * a phone number before its own MCP, hooks, and durable bundle are actually
529
+ * in place.
510
530
  */
511
531
  isWired?(): boolean;
512
532
  /** Host-specific doctor checks, appended after the shared ones. */
@@ -654,4 +674,4 @@ declare function atomicWriteFile(filePath: string, data: string, mode?: number):
654
674
  declare function atomicCopyFile(source: string, destination: string, mode?: number): void;
655
675
  declare function readJsonFile<T>(filePath: string): T | null;
656
676
 
657
- export { ANCHOR_END, ANCHOR_START, type AlwaysOnState, type AnchorAction, CODING_AGENTS_CLIENT_HEADERS, CODING_AGENTS_CLIENT_IDENTITY, type Credentials, DEFAULT_API_BASE, type DoctorCheck, type DoctorOpts, HEARTBEAT_FILE, type HookContext, type HookDialect, type HookInput, type HookRunners, type HookState, type HostCopy, type HostProfile, type IdentityCommands, type LockHandle, type ManualCopy, type MessageContext, type PendingRegistration, type Plan, type RegisterOpts, type ResolvedIdentity, type ServiceOpts, type ServiceRef, type SessionStartResult, type StopResult, type SyncRow, VERSION, type Verdict, type WireConfig, WireError, absoluteUtc, acquireLeaderLock, alwaysOnHealth, alwaysOnOptedOut, alwaysOnState, alwaysOnWanted, anchorLabelOf, atomicCopyFile, atomicWriteFile, beat, claimReply, claimReplyBatch, clearAlwaysOnInstalledVersion, clearAlwaysOnOptOut, clearAlwaysOnWanted, clearCredentials, clearForegroundTurn, clearOfferDeclined, clearPending, clearSessionActive, contextOf, createHookRunners, createIdentityCommands, credentialsPath, formatAlwaysOnDown, formatRegistrationOffer, formatSessionStart, formatStopPickup, formatWhen, getContinuations, getMeLite, hasAnchorAt, hooksDisabled, idle, installService, lastDeliveryId, launchdPlist, log, markAlwaysOnInstalledVersion, markAlwaysOnOptOut, markAlwaysOnWanted, markForegroundTurn, markSessionActive, offerDeclined, pendingPath, planForTest, readAlwaysOnInstalledVersion, readAnchorHandleAt, readAnchorHandleFrom, readCredentials, readHookInput, readJsonFile, readPending, readState, recordContinuation, recordOfferDeclined, recordRegistrationOffer, relativeAge, relativeWhen, removeAnchorAt, renderAnchorBlock, renderDeclinedBlock, renderManual, renderUnregisteredBlock, resetSession, resolveIdentity, serviceDefinitionCurrent, serviceInstalled, serviceStatus, sessionEnd, sessionStart, setPendingAck, shouldOfferRegistration, statePath, stop, stripAnchorBlock, syncAck, syncPeek, systemdQuote, systemdUnit, takePendingAck, uninstallService, upsertAnchorBlock, userPrompt, writeAnchor, writeCredentials, writePending, writeState, xmlEscape };
677
+ export { ANCHOR_END, ANCHOR_START, type AlwaysOnState, type AnchorAction, CODING_AGENTS_CLIENT_HEADERS, CODING_AGENTS_CLIENT_IDENTITY, type Credentials, DEFAULT_API_BASE, type DoctorCheck, type DoctorOpts, HEARTBEAT_FILE, type HookContext, type HookDialect, type HookInput, type HookRunners, type HookState, type HostCopy, type HostProfile, type IdentityCommands, type LockHandle, type ManualCopy, type MessageContext, type PendingRegistration, type Plan, type RegisterOpts, type ResolvedIdentity, type ServiceOpts, type ServiceRef, type SessionStartResult, type StopResult, type SyncRow, type UserPromptResult, VERSION, type Verdict, type WireConfig, WireError, absoluteUtc, acquireLeaderLock, alwaysOnHealth, alwaysOnOptedOut, alwaysOnState, alwaysOnWanted, anchorLabelOf, atomicCopyFile, atomicWriteFile, beat, claimReply, claimReplyBatch, clearAlwaysOnInstalledVersion, clearAlwaysOnOptOut, clearAlwaysOnWanted, clearCredentials, clearForegroundTurn, clearOfferDeclined, clearPending, clearSessionActive, contextOf, createHookRunners, createIdentityCommands, credentialsPath, formatAlwaysOnDown, formatRegistrationOffer, formatSessionStart, formatStopPickup, formatWhen, getContinuations, getMeLite, hasAnchorAt, hooksDisabled, idle, installService, lastDeliveryId, launchdPlist, log, markAlwaysOnInstalledVersion, markAlwaysOnOptOut, markAlwaysOnWanted, markForegroundTurn, markSessionActive, offerDeclined, pendingPath, planForTest, readAlwaysOnInstalledVersion, readAnchorHandleAt, readAnchorHandleFrom, readCredentials, readHookInput, readJsonFile, readPending, readState, recordContinuation, recordOfferDeclined, recordRegistrationOffer, relativeAge, relativeWhen, removeAnchorAt, renderAnchorBlock, renderDeclinedBlock, renderManual, renderUnregisteredBlock, resetSession, resolveIdentity, serviceDefinitionCurrent, serviceInstalled, serviceStatus, sessionEnd, sessionStart, setPendingAck, shouldOfferRegistration, statePath, stop, stripAnchorBlock, syncAck, syncPeek, systemdQuote, systemdUnit, takePendingAck, uninstallService, upsertAnchorBlock, userPrompt, writeAnchor, writeCredentials, writePending, writeState, xmlEscape };
package/dist/index.js CHANGED
@@ -49,20 +49,21 @@ import {
49
49
  syncPeek,
50
50
  writeCredentials,
51
51
  writePending
52
- } from "./chunk-M2X5WY7Q.js";
52
+ } from "./chunk-YWX7E5VW.js";
53
53
 
54
54
  // src/identity/state.ts
55
55
  var SESSION_TTL_MS = 48 * 60 * 60 * 1e3;
56
56
  var SessionStateSchema = external_exports.object({
57
57
  continuations: external_exports.number().int().min(0),
58
58
  updated_at: external_exports.string(),
59
- // Ack cursor for the batch the session-start hook injected but has NOT
60
- // yet committed. Committed by the user-prompt hook proof the session
61
- // actually ran a turn. A session that dies before its first prompt
62
- // (arg-error invocations, crashed startups) leaves this uncommitted and
63
- // the batch re-digests next session instead of being consumed by a
64
- // ghost. Live-fire lesson, 2026-07-12.
65
- pending_ack: external_exports.string().optional()
59
+ // Ack cursor for hook context already handed to the host but not yet proven
60
+ // through a completed model boundary. UserPromptSubmit and Stop stage it;
61
+ // the following Stop commits it. A session that dies in between leaves the
62
+ // server delivery unacked, preferring a later duplicate over silent loss.
63
+ pending_ack: external_exports.string().optional(),
64
+ // Stop context needs the specific host-created continuation, not merely an
65
+ // unrelated later turn that happens to reach Stop.
66
+ pending_ack_requires_continuation: external_exports.boolean().optional()
66
67
  });
67
68
  var StateSchema = external_exports.object({
68
69
  sessions: external_exports.record(SessionStateSchema).default({}),
@@ -102,9 +103,15 @@ function getContinuations(home, sessionKey) {
102
103
  function recordContinuation(home, sessionKey, now = /* @__PURE__ */ new Date()) {
103
104
  const state = readState(home);
104
105
  prune(state, now);
105
- const current = state.sessions[sessionKey]?.continuations ?? 0;
106
+ const existing = state.sessions[sessionKey];
107
+ const current = existing?.continuations ?? 0;
106
108
  const next = current + 1;
107
- state.sessions[sessionKey] = { continuations: next, updated_at: now.toISOString() };
109
+ state.sessions[sessionKey] = {
110
+ continuations: next,
111
+ updated_at: now.toISOString(),
112
+ ...existing?.pending_ack !== void 0 ? { pending_ack: existing.pending_ack } : {},
113
+ ...existing?.pending_ack_requires_continuation === true ? { pending_ack_requires_continuation: true } : {}
114
+ };
108
115
  writeState(home, state);
109
116
  return next;
110
117
  }
@@ -114,23 +121,31 @@ function resetSession(home, sessionKey) {
114
121
  delete state.sessions[sessionKey];
115
122
  writeState(home, state);
116
123
  }
117
- function setPendingAck(home, sessionKey, cursor, now = /* @__PURE__ */ new Date()) {
124
+ function setPendingAck(home, sessionKey, cursor, now = /* @__PURE__ */ new Date(), requiresContinuation = false) {
118
125
  const state = readState(home);
119
126
  prune(state, now);
120
127
  const existing = state.sessions[sessionKey];
121
128
  state.sessions[sessionKey] = {
122
129
  continuations: existing?.continuations ?? 0,
123
130
  updated_at: now.toISOString(),
124
- pending_ack: cursor
131
+ pending_ack: cursor,
132
+ ...requiresContinuation ? { pending_ack_requires_continuation: true } : {}
125
133
  };
126
134
  writeState(home, state);
127
135
  }
136
+ function getPendingAck(home, sessionKey) {
137
+ return readState(home).sessions[sessionKey]?.pending_ack ?? null;
138
+ }
139
+ function pendingAckRequiresContinuation(home, sessionKey) {
140
+ return readState(home).sessions[sessionKey]?.pending_ack_requires_continuation === true;
141
+ }
128
142
  function takePendingAck(home, sessionKey, now = /* @__PURE__ */ new Date()) {
129
143
  const state = readState(home);
130
144
  const entry = state.sessions[sessionKey];
131
145
  if (entry?.pending_ack === void 0) return null;
132
146
  const cursor = entry.pending_ack;
133
147
  delete entry.pending_ack;
148
+ delete entry.pending_ack_requires_continuation;
134
149
  entry.updated_at = now.toISOString();
135
150
  writeState(home, state);
136
151
  return cursor;
@@ -350,7 +365,7 @@ function formatStopPickup(handle, rows) {
350
365
  ].join("\n");
351
366
  }
352
367
  function formatAlwaysOnDown(copy) {
353
- return `\u26A0 Always-on is down \u2014 while you are away I won\u2019t be able to answer messages (they queue for your next session, nothing is lost). Turn it back on: \`${copy.invoke} daemon install\``;
368
+ return `\u26A0 Always-on is down \u2014 while you are away I won\u2019t be able to answer messages (they remain stored and queue for your next session). Turn it back on: \`${copy.invoke} daemon install\``;
354
369
  }
355
370
  function formatRegistrationOffer(copy, alwaysOn = "off") {
356
371
  const { invoke, label } = copy;
@@ -430,7 +445,7 @@ function renderDeclinedBlock(copy) {
430
445
  }
431
446
 
432
447
  // src/hooks/engine.ts
433
- var SESSION_START_PEEK_LIMIT = 100;
448
+ var USER_PROMPT_PEEK_LIMIT = 100;
434
449
  var STOP_PEEK_LIMIT = 50;
435
450
  var DEFAULT_MAX_CONTINUATIONS = 5;
436
451
  var FOREGROUND_TURN_TTL_SECONDS = 600;
@@ -489,46 +504,46 @@ async function sessionStart(ctx, input) {
489
504
  }
490
505
  return none;
491
506
  }
492
- const cfg = { apiKey: identity.apiKey, apiBase: identity.apiBase };
493
507
  const h = alwaysOnHealth(ctx.home);
494
508
  const alert = h.wanted && !h.healthy ? formatAlwaysOnDown(ctx.copy) : null;
495
- const peeked = ackableRows(await syncPeek(cfg, { limit: SESSION_START_PEEK_LIMIT }));
496
- const rows = peeked.length > 0 ? await claimContiguousPrefix(cfg, peeked, `session:${input.sessionId}`) : [];
497
- if (rows.length === 0) return { context: alert };
498
- const handle = await resolveHandle(cfg, identity.handle);
499
- const digest = formatSessionStart(handle, rows);
500
- const context = alert !== null ? `${alert}
501
-
502
- ${digest}` : digest;
503
- const cursor = lastDeliveryId(rows);
504
- if (cursor !== null) setPendingAck(ctx.home, input.sessionId, cursor);
505
- return { context };
509
+ return { context: alert };
506
510
  } catch (err) {
507
511
  log.warn(`session-start hook degraded to no-op: ${String(err)}`);
508
512
  return none;
509
513
  }
510
514
  }
511
515
  async function userPrompt(ctx, input) {
516
+ const none = { context: null, stage: () => {
517
+ } };
512
518
  try {
513
- if (hooksDisabled()) return;
519
+ if (hooksDisabled()) return none;
514
520
  const identity = resolveIdentity(ctx.home);
515
- if (identity === null) return;
521
+ if (identity === null) return none;
516
522
  const cfg = { apiKey: identity.apiKey, apiBase: identity.apiBase };
517
523
  await markForegroundTurn(cfg, input.sessionId, FOREGROUND_TURN_TTL_SECONDS);
518
- const cursor = takePendingAck(ctx.home, input.sessionId);
519
- if (cursor === null) return;
520
- try {
521
- await syncAck(cfg, cursor);
522
- } catch (err) {
523
- setPendingAck(ctx.home, input.sessionId, cursor);
524
- log.warn(`user-prompt ack failed (will retry next prompt): ${String(err)}`);
525
- }
524
+ if (getPendingAck(ctx.home, input.sessionId) !== null) return none;
525
+ const peeked = ackableRows(await syncPeek(cfg, { limit: USER_PROMPT_PEEK_LIMIT }));
526
+ if (peeked.length === 0) return none;
527
+ const rows = await claimContiguousPrefix(
528
+ cfg,
529
+ peeked,
530
+ `session:${input.sessionId}`
531
+ );
532
+ if (rows.length === 0) return none;
533
+ const cursor = lastDeliveryId(rows);
534
+ if (cursor === null) return none;
535
+ const handle = await resolveHandle(cfg, identity.handle);
536
+ return {
537
+ context: formatSessionStart(handle, rows),
538
+ stage: () => setPendingAck(ctx.home, input.sessionId, cursor)
539
+ };
526
540
  } catch (err) {
527
541
  log.warn(`user-prompt hook degraded to no-op: ${String(err)}`);
542
+ return none;
528
543
  }
529
544
  }
530
545
  async function stop(ctx, input) {
531
- const none = { reason: null, commit: async () => {
546
+ const none = { reason: null, stage: () => {
532
547
  } };
533
548
  let cfg = null;
534
549
  try {
@@ -536,6 +551,24 @@ async function stop(ctx, input) {
536
551
  const identity = resolveIdentity(ctx.home);
537
552
  if (identity === null) return none;
538
553
  cfg = { apiKey: identity.apiKey, apiBase: identity.apiBase };
554
+ const needsContinuation = pendingAckRequiresContinuation(
555
+ ctx.home,
556
+ input.sessionId
557
+ );
558
+ if (needsContinuation && input.stopHookActive !== true) {
559
+ takePendingAck(ctx.home, input.sessionId);
560
+ }
561
+ const pending = needsContinuation && input.stopHookActive !== true ? null : takePendingAck(ctx.home, input.sessionId);
562
+ if (pending !== null) {
563
+ try {
564
+ await syncAck(cfg, pending);
565
+ } catch (err) {
566
+ setPendingAck(ctx.home, input.sessionId, pending);
567
+ await clearForegroundTurn(cfg, input.sessionId);
568
+ log.warn(`completed-turn ack failed (messages stay queued): ${String(err)}`);
569
+ return none;
570
+ }
571
+ }
539
572
  await markForegroundTurn(cfg, input.sessionId, FOREGROUND_TURN_TTL_SECONDS);
540
573
  const cap = maxContinuations();
541
574
  if (getContinuations(ctx.home, input.sessionId) >= cap) {
@@ -559,15 +592,17 @@ async function stop(ctx, input) {
559
592
  const handle = await resolveHandle(cfg, identity.handle);
560
593
  const reason = formatStopPickup(handle, rows);
561
594
  const cursor = lastDeliveryId(rows);
562
- const claimedCfg = cfg;
563
595
  return {
564
596
  reason,
565
- commit: async () => {
566
- if (cursor === null) return;
567
- try {
568
- await syncAck(claimedCfg, cursor);
569
- } catch (err) {
570
- log.warn(`stop ack failed (messages stay queued): ${String(err)}`);
597
+ stage: () => {
598
+ if (cursor !== null) {
599
+ setPendingAck(
600
+ ctx.home,
601
+ input.sessionId,
602
+ cursor,
603
+ /* @__PURE__ */ new Date(),
604
+ true
605
+ );
571
606
  }
572
607
  }
573
608
  };
@@ -617,7 +652,15 @@ async function readHookInput(stream = process.stdin) {
617
652
  }
618
653
  const sessionId = firstString(parsed, ["session_id", "sessionId", "thread_id", "conversation_id"]) ?? "unknown";
619
654
  const source = firstString(parsed, ["source"]) ?? void 0;
620
- return { sessionId, source };
655
+ const stopHookActive = firstBoolean(parsed, [
656
+ "stop_hook_active",
657
+ "stopHookActive"
658
+ ]);
659
+ return {
660
+ sessionId,
661
+ source,
662
+ ...stopHookActive !== void 0 ? { stopHookActive } : {}
663
+ };
621
664
  }
622
665
  function firstString(obj, keys) {
623
666
  for (const key of keys) {
@@ -626,6 +669,12 @@ function firstString(obj, keys) {
626
669
  }
627
670
  return null;
628
671
  }
672
+ function firstBoolean(obj, keys) {
673
+ for (const key of keys) {
674
+ if (typeof obj[key] === "boolean") return obj[key];
675
+ }
676
+ return void 0;
677
+ }
629
678
 
630
679
  // src/hooks/runners.ts
631
680
  function createHookRunners(context, dialect) {
@@ -642,7 +691,10 @@ function createHookRunners(context, dialect) {
642
691
  async runUserPrompt() {
643
692
  try {
644
693
  const input = await readHookInput();
645
- await userPrompt(context(), input);
694
+ const { context: text, stage } = await userPrompt(context(), input);
695
+ if (text === null) return;
696
+ dialect.printJson(dialect.userPromptOutput(text));
697
+ stage();
646
698
  } catch (err) {
647
699
  log.warn(`user-prompt hook degraded to no-op: ${String(err)}`);
648
700
  }
@@ -650,10 +702,10 @@ function createHookRunners(context, dialect) {
650
702
  async runStop() {
651
703
  try {
652
704
  const input = await readHookInput();
653
- const { reason, commit } = await stop(context(), input);
705
+ const { reason, stage } = await stop(context(), input);
654
706
  if (reason === null) return;
655
707
  dialect.printJson(dialect.stopOutput(reason));
656
- await commit();
708
+ stage();
657
709
  } catch (err) {
658
710
  log.warn(`stop hook degraded to no-op: ${String(err)}`);
659
711
  }
@@ -1167,7 +1219,7 @@ function renderManual(copy, opts = {}) {
1167
1219
  "",
1168
1220
  "You live in sessions: you exist while your human is working with you, and the AgentChat server holds your messages while you don't. There is nothing to poll on a timer and nothing to babysit:",
1169
1221
  "",
1170
- "- **Session start** \u2014 a digest of everything that queued while you were away is injected into your context: senders, counts, latest snippets. Those messages are marked delivered the moment you see the digest.",
1222
+ "- **First real prompt** \u2014 a digest of everything that queued while you were away is injected into that prompt: senders, counts, latest snippets. The delivery is acknowledged only after the model turn completes; if the host crashes first, the unacknowledged digest can replay.",
1171
1223
  '- **While you work** \u2014 replies that arrive mid-task are handed to you at the end of a turn ("While you were working\u2026"). Handle them, then finish. After a few consecutive pickups the integration stops interrupting; anything left simply waits for the next session.',
1172
1224
  "- **On demand** \u2014 `agentchat_list_inbox` shows your conversations, `agentchat_get_conversation` reads a thread's history. Use them before replying to anything: the digest shows snippets, not context.",
1173
1225
  '- **Between sessions** \u2014 the always-on daemon answers for you (below). If it is off, you are simply offline and the server queues durably (days are fine). Trust it: no "did you get this?" re-sends, ever.',
@@ -1176,7 +1228,7 @@ function renderManual(copy, opts = {}) {
1176
1228
  "",
1177
1229
  "## Always-on (out of session)",
1178
1230
  "",
1179
- 'A small always-on daemon is installed with the integration and runs in the background, so "between sessions" is not offline: you answer DMs whenever the machine is up, not only while your human is working. It is the same you \u2014 one identity \u2014 spawning a headless turn to reply, and it steps aside the instant a live session takes over, so nothing is ever answered twice.',
1231
+ 'A small always-on daemon is installed with the integration and runs in the background, so "between sessions" is not offline: you answer DMs whenever the machine is up, not only while your human is working. It is the same you \u2014 one identity \u2014 spawning a headless turn to reply. Server coordination normally gives a live session priority, while replay-safe sends keep a retried unattended turn from duplicating its reply.',
1180
1232
  "",
1181
1233
  "- **It is already on.** Nothing to enable, and you should not offer to.",
1182
1234
  `- **Honor a "session-only" request.** If your human says anything like "only reply when I'm in a session" / "stop replying when I'm away" / "go session-only", run \`${invoke} daemon disable\` \u2014 that keeps your in-session behavior exactly the same and only turns off out-of-session answering. To resume: \`${invoke} daemon install\`.`,