When the agent needs a human decision before proceeding, it raises a structured pause: a question (multiple-choice or open-ended), and it blocks on the answer. In Alembic terms this is the T4 human-gate surface (ADR-0005). Python blocks a thread on a threading.Event; Node has no blocking thread, so the faithful equivalent is a promise + a resolver registry + a timeout. A CLONE of Hermes' clarify_tool.py + clarify_gateway.py.
// packages/hermes/src/clarify/gateway.ts:82-110 — ask (condensed) const parsed = clarifyQuestionSchema.safeParse(question); if (!parsed.success) return err(new Error(`Invalid clarify question: …`)); // fails closed SYNCHRONOUSLY const id = this.mintId(); return new Promise((resolvePromise) => { const timer = setTimeout(() => { this.entries.delete(id); // drop the entry… resolvePromise(err(new Error('clarify timed out'))); // …never hang }, timeoutMs); timer.unref?.(); // let the process exit while pending const settle = (result) => { clearTimeout(timer); this.entries.delete(id); resolvePromise(result); }; this.entries.set(id, { id, question: valid, settle, timer }); });
timer.unref() lets the Node process exit even while a clarify is pending — a hanging human prompt won't keep the runtime alive forever.A question is a discriminated union on kind. A choice question carries 1..MAX_CHOICES options; MAX_CHOICES = 4 is the data cap (the UI's 5th "Other" option is a presentation concern, not modelled here):
// packages/hermes/src/clarify/types.ts:48-61 export const clarifyQuestionSchema = z.discriminatedUnion('kind', [ z.object({ kind: z.literal('choice'), prompt: z.string().min(1, 'prompt cannot be empty'), choices: z.array(z.string().min(1, …)) .min(1, 'choice question needs at least one choice') .max(MAX_CHOICES, `choice question allows at most ${MAX_CHOICES} choices`), }), z.object({ kind: z.literal('open'), prompt: z.string().min(1, …) }), ]);
A subtle robustness helper rides along: coerceChoices, a clone of the source's _flatten_choice. LLMs sometimes emit dict-shaped choices ([{description:'…'}]) instead of bare strings; this unwraps them by canonical label keys in priority order:
// packages/hermes/src/clarify/types.ts:104-121 — flattenChoice const CHOICE_LABEL_KEYS = ['label', 'description', 'text', 'title'] as const; // name/value EXCLUDED // string ⇒ trimmed; dict ⇒ first non-empty canonical key; else ⇒ '' (dropped)
name/value are deliberately excluded — they carry raw enum values/identifiers, not human labels, and a garbage label is worse than no choice at all (the dict collapses to '' and is dropped). Note coerceChoices does not cap to 4 — capping is the schema's job, so an over-long list fails closed at validation rather than being silently truncated.
Zod can check a response's shape, but not whether it fits the live question. resolve enforces the cross-field invariants — kind match and index-in-range — that the schema cannot:
// packages/hermes/src/clarify/gateway.ts:143-168 — validateResponse if (value.kind !== question.kind) return err(new Error(`Response kind '${value.kind}' does not match question kind '${question.kind}'.`)); if (value.kind === 'choice' && question.kind === 'choice') { if (value.index >= question.choices.length) return err(new Error(`Choice index ${value.index} out of range …`)); }
When resolve gets an invalid response (wrong kind, out-of-range index), it returns err but leaves the entry pending — so a corrected response can still arrive and settle the same promise. Only a valid response settles (and removes) the entry. The test proves it: a kind-mismatch resolve fails yet pending() still lists the id; a follow-up correct resolve then settles it. By contrast, an unknown or already-settled id is a plain err ("Unknown or already-resolved"), and a double-resolve fails because the first one already removed the entry.
Math.random()Ids come from an injectable factory, defaulting to a monotonic counter — never Math.random()/Date.now(), which the engine's plan VM rejects and which would break replay:
// packages/hermes/src/clarify/gateway.ts:176-182 export const monotonicIdFactory = (prefix = 'clarify'): (() => ClarifyId) => { let n = 0; return () => { n += 1; return `${prefix}-${n}`; }; };
The default timeout is DEFAULT_CLARIFY_TIMEOUT_MS = 600_000 (10 minutes), mirroring the source's 600s. Tests drive it with vitest fake timers, advancing past the deadline to prove the entry is dropped and the promise resolves to err — no leak, no hang.
resolve is called with an open-text answer for a choice question. What happens?validateResponse rejects a kind mismatch with err, but only a valid response settles the entry — an invalid one leaves it pending for a re-prompt. The test confirms pending() still lists the id afterward.coerceChoices'. ask validates the question first and returns err synchronously, registering nothing — failing closed rather than silently truncating untrusted input.ClarifyGateway mint ids via an injected monotonicIdFactory instead of a random id?Clock: replace a non-deterministic global with an injected seam. Tests inject monotonicIdFactory('q') so they can assert on q-1, q-2.Promise the caller awaits; a resolver registry (Map<id, pending>) lets a platform callback settle it by id, and a setTimeout guarantees it never hangs. This is the faithful equivalent of Python's threading.Event in an async runtime.err while the question stays pending for a corrected response. Only a valid answer (or the timeout) removes the entry.