@oharness/server 0.7.0 → 0.7.1

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/server.js CHANGED
@@ -204,7 +204,7 @@ export async function startWebServer(opts) {
204
204
  const makeRunner = (session, harness) => {
205
205
  const ledger = new ChangeLedger(session.cwd);
206
206
  ledger.attach(harness.hooks);
207
- return { session, agent: harness.agent(session), harness, ledger };
207
+ return { session, agent: harness.agent(session), harness, ledger, queue: [] };
208
208
  };
209
209
  /** The runner a request names, or the most recently focused one. */
210
210
  let focused = opts.session.id;
@@ -2403,9 +2403,6 @@ export async function startWebServer(opts) {
2403
2403
  if (!prompt && !ids.length && !references.length) {
2404
2404
  return send(res, 400, { error: "prompt is required" });
2405
2405
  }
2406
- // Per runner, not global: a turn in one tab must not block another.
2407
- if (runner.running)
2408
- return send(res, 409, { error: "a turn is already running" });
2409
2406
  const staged = ids
2410
2407
  .map((id) => (typeof id === "string" ? attachments.get(id) : undefined))
2411
2408
  .filter((entry) => Boolean(entry));
@@ -2416,6 +2413,19 @@ export async function startWebServer(opts) {
2416
2413
  // a composer is known to have moved on, so it is a good moment to ask
2417
2414
  // what else has been abandoned.
2418
2415
  sweepAttachments();
2416
+ // Per runner, not global: a turn in one tab must not block another.
2417
+ // A second prompt into a busy runner waits, and is shown waiting. It
2418
+ // is not handed to the running turn on its own: putting words in front
2419
+ // of a model mid-thought is a thing to choose, not a thing that
2420
+ // happens because you typed fast. /sendnow is that choice.
2421
+ //
2422
+ // This used to 409, and the page discarded the reply, so an impatient
2423
+ // Enter cleared the composer and lost what had been typed.
2424
+ if (runner.running) {
2425
+ runner.queue.push({ id: randomUUID(), prompt, staged, references });
2426
+ broadcastQueue(runner);
2427
+ return send(res, 202, { ok: true, queued: true });
2428
+ }
2419
2429
  void runTurn(runner, prompt, staged, references);
2420
2430
  return send(res, 202, { ok: true, attached: staged.length, referenced: references.length });
2421
2431
  }
@@ -2451,6 +2461,84 @@ export async function startWebServer(opts) {
2451
2461
  runnerFor(body.session)?.running?.abort();
2452
2462
  }
2453
2463
  return send(res, 200, { ok: true });
2464
+ case "POST /unqueue": {
2465
+ const body = await readJson(req);
2466
+ const runner = runnerFor(body.session);
2467
+ if (!runner)
2468
+ return send(res, 404, { error: "no such session" });
2469
+ // Either kind: one handed to the running turn but not yet read, or
2470
+ // one still waiting for the turn to end.
2471
+ if (typeof body.id === "string" && runner.agent.retract(body.id)) {
2472
+ broadcastQueue(runner);
2473
+ return send(res, 200, { ok: true });
2474
+ }
2475
+ const at = runner.queue.findIndex((entry) => entry.id === body.id);
2476
+ if (at >= 0) {
2477
+ // The bytes came out of the staging map when this was queued, so
2478
+ // they are held by the entry alone and go when it does.
2479
+ runner.queue.splice(at, 1);
2480
+ broadcastQueue(runner);
2481
+ }
2482
+ return send(res, 200, { ok: true });
2483
+ }
2484
+ /**
2485
+ * One waiting message, now, without waiting for its place in the queue.
2486
+ *
2487
+ * While a turn runs that means handing it to the turn — the same path a
2488
+ * prompt typed mid-turn takes — so it is read at the next gap rather
2489
+ * than after the whole thing. Nothing is interrupted: "now" here is as
2490
+ * soon as the conversation has a place to put it.
2491
+ *
2492
+ * A message carrying files cannot ride the inbox, so it moves to the
2493
+ * head of the queue instead and the reply says so.
2494
+ */
2495
+ case "POST /sendnow": {
2496
+ const body = await readJson(req);
2497
+ const runner = runnerFor(body.session);
2498
+ if (!runner)
2499
+ return send(res, 404, { error: "no such session" });
2500
+ const at = runner.queue.findIndex((entry) => entry.id === body.id);
2501
+ if (at < 0)
2502
+ return send(res, 404, { error: "no such queued message" });
2503
+ const entry = runner.queue[at];
2504
+ if (!runner.running) {
2505
+ runner.queue.splice(at, 1);
2506
+ broadcastQueue(runner);
2507
+ void runTurn(runner, entry.prompt, entry.staged, entry.references);
2508
+ return send(res, 202, { ok: true, sent: true });
2509
+ }
2510
+ const plain = entry.prompt && !entry.staged.length && !entry.references.length;
2511
+ if (plain && runner.agent.interject(entry.prompt)) {
2512
+ runner.queue.splice(at, 1);
2513
+ broadcastQueue(runner);
2514
+ return send(res, 202, { ok: true, interjected: true });
2515
+ }
2516
+ // Cannot be handed over, so the most that can be done is to put it
2517
+ // first in line for when the turn ends.
2518
+ runner.queue.splice(at, 1);
2519
+ runner.queue.unshift(entry);
2520
+ broadcastQueue(runner);
2521
+ return send(res, 200, { ok: true, moved: true });
2522
+ }
2523
+ /**
2524
+ * Send what is waiting, now.
2525
+ *
2526
+ * The queue drains itself after a turn that ends on its own, but not
2527
+ * after an interrupt or an error — stopping means stop, and a message
2528
+ * typed before the failure should not fire into whatever caused it.
2529
+ * This is how the page restarts a queue left standing that way.
2530
+ */
2531
+ case "POST /flush": {
2532
+ const body = await readJson(req);
2533
+ const runner = runnerFor(body.session);
2534
+ if (!runner)
2535
+ return send(res, 404, { error: "no such session" });
2536
+ if (runner.running)
2537
+ return send(res, 409, { error: "a turn is already running" });
2538
+ if (!drainQueue(runner))
2539
+ return send(res, 200, { ok: true, sent: false });
2540
+ return send(res, 202, { ok: true, sent: true });
2541
+ }
2454
2542
  default:
2455
2543
  return send(res, 404, { error: "not found" });
2456
2544
  }
@@ -2669,6 +2757,9 @@ export async function startWebServer(opts) {
2669
2757
  contextWindow: model.contextWindow,
2670
2758
  contextUsed: model.contextWindow ? Math.min(1, used / model.contextWindow) : undefined,
2671
2759
  busy: Boolean(runner.running),
2760
+ // So a reload, or a second tab, sees what is still waiting. The queue
2761
+ // lives on the runner rather than in the page for exactly this reason.
2762
+ queued: queueView(runner),
2672
2763
  };
2673
2764
  }
2674
2765
  /**
@@ -2885,6 +2976,46 @@ export async function startWebServer(opts) {
2885
2976
  });
2886
2977
  return snapshot;
2887
2978
  }
2979
+ /**
2980
+ * What the page needs to draw the waiting chips, without the bytes.
2981
+ *
2982
+ * Interjections first, and marked as such: they reach the model during the
2983
+ * running turn, while the queue waits for it to finish. Drawn as one list
2984
+ * because that is the order the conversation will read them in.
2985
+ */
2986
+ const queueView = (runner) => [
2987
+ ...runner.agent.pendingInterjections.map((entry) => ({
2988
+ id: entry.id,
2989
+ text: entry.text,
2990
+ attachments: [],
2991
+ references: [],
2992
+ soon: true,
2993
+ })),
2994
+ ...runner.queue.map((entry) => ({
2995
+ id: entry.id,
2996
+ text: entry.prompt,
2997
+ attachments: entry.staged.map((file) => file.name),
2998
+ references: entry.references,
2999
+ soon: false,
3000
+ })),
3001
+ ];
3002
+ const broadcastQueue = (runner) => {
3003
+ broadcast({ type: "queue", session: runner.session.id, queued: queueView(runner) });
3004
+ };
3005
+ /**
3006
+ * Start the next waiting prompt, if there is one.
3007
+ *
3008
+ * Returns whether a turn was started, so a caller can tell an empty queue
3009
+ * from one that is now running.
3010
+ */
3011
+ function drainQueue(runner) {
3012
+ const next = runner.queue.shift();
3013
+ if (!next)
3014
+ return false;
3015
+ broadcastQueue(runner);
3016
+ void runTurn(runner, next.prompt, next.staged, next.references);
3017
+ return true;
3018
+ }
2888
3019
  async function runTurn(runner, prompt, staged = [], references = []) {
2889
3020
  const controller = new AbortController();
2890
3021
  runner.running = controller;
@@ -2907,6 +3038,7 @@ export async function startWebServer(opts) {
2907
3038
  const note = referenceNote(references);
2908
3039
  const text = note ? `${note}\n\n${prompt}` : prompt;
2909
3040
  const input = staged.length ? buildMessage(text, staged) : text;
3041
+ let failed = false;
2910
3042
  try {
2911
3043
  for await (const event of runner.agent.run(input, { signal: controller.signal })) {
2912
3044
  // The agent's own question_request is not forwarded: the page draws
@@ -2915,10 +3047,25 @@ export async function startWebServer(opts) {
2915
3047
  // the copy without that id could only ever 404.
2916
3048
  if (event.type === "question_request")
2917
3049
  continue;
3050
+ // The agent reports a provider failure as an event and breaks its own
3051
+ // loop rather than throwing, so the catch below never sees it — and a
3052
+ // queue that drained into a dead provider would replay the failure
3053
+ // once per waiting message.
3054
+ if (event.type === "error" && event.fatal)
3055
+ failed = true;
3056
+ // Read into the conversation: the chip goes, and the words appear in
3057
+ // the transcript where the model saw them rather than where they were
3058
+ // typed. `prompt` is what the page draws a user message from.
3059
+ if (event.type === "interjected") {
3060
+ broadcastQueue(runner);
3061
+ broadcast({ type: "prompt", session: id, text: event.text, interjected: true });
3062
+ continue;
3063
+ }
2918
3064
  broadcast({ ...event, session: id });
2919
3065
  }
2920
3066
  }
2921
3067
  catch (err) {
3068
+ failed = true;
2922
3069
  broadcast({
2923
3070
  type: "error",
2924
3071
  session: id,
@@ -2942,12 +3089,24 @@ export async function startWebServer(opts) {
2942
3089
  }
2943
3090
  finally {
2944
3091
  runner.running = undefined;
3092
+ // The window between the loop's last read of the inbox and the run
3093
+ // admitting it is over. Whatever landed in it is put at the head of the
3094
+ // queue rather than left unread inside a finished agent.
3095
+ const unread = runner.agent.takeUnread();
3096
+ for (let i = unread.length - 1; i >= 0; i--) {
3097
+ runner.queue.unshift({ id: unread[i].id, prompt: unread[i].text, staged: [], references: [] });
3098
+ }
2945
3099
  broadcast({
2946
3100
  type: "idle",
2947
3101
  session: id,
2948
3102
  state: state(runner),
2949
3103
  changes: runner.ledger.list(),
2950
3104
  });
3105
+ // Only after a turn that ended on its own. An interrupt means stop, and
3106
+ // an error would usually meet the next prompt with the same error, so
3107
+ // both leave the queue standing for the person to send or drop.
3108
+ if (!controller.signal.aborted && !failed)
3109
+ drainQueue(runner);
2951
3110
  }
2952
3111
  }
2953
3112
  function stream(res) {