@turns/dev 0.0.17 → 0.0.19

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.
@@ -198,11 +198,21 @@ class LineTransport {
198
198
  }
199
199
  }
200
200
  }
201
+ /**
202
+ * How long a background notification's `result` is held before it is taken as
203
+ * the end of the prompt's turn. Claude answers a task notification with a turn
204
+ * of its own, stamped `origin.kind: "task-notification"`; when a prompt is
205
+ * waiting behind it, that turn's `result` arrives first and the prompt's turn
206
+ * starts right after it. If Claude instead folded the prompt into the
207
+ * notification's turn, nothing follows, and the held result is the prompt's.
208
+ */
209
+ const CLAUDE_NOTIFICATION_RESULT_GRACE_MS = 2_000;
201
210
  class ClaudeTransport extends LineTransport {
202
211
  sessionId;
203
212
  permissionMode;
204
213
  turnWaiter;
205
214
  control;
215
+ heldResult = null;
206
216
  constructor(anchor, createSession) {
207
217
  const profile = { command: 'claude' };
208
218
  const nativePermissionMode = anchor.permissionMode === 'default' ? 'manual' : anchor.permissionMode;
@@ -238,6 +248,13 @@ class ClaudeTransport extends LineTransport {
238
248
  onFrame(frame) {
239
249
  if (!frame || typeof frame !== 'object')
240
250
  return;
251
+ // Another turn starting means a held notification result was not the
252
+ // prompt's: keep waiting for the result of the turn now running.
253
+ if (this.heldResult &&
254
+ ((frame.type === 'system' && frame.subtype === 'init') || frame.type === 'stream_event' || frame.type === 'assistant')) {
255
+ clearTimeout(this.heldResult.timer);
256
+ this.heldResult = null;
257
+ }
241
258
  if (frame.type === 'system' && frame.subtype === 'init' && frame.session_id) {
242
259
  this.sessionId = String(frame.session_id);
243
260
  if (this.sessionId !== this.anchor.agentSessionId)
@@ -245,11 +262,20 @@ class ClaudeTransport extends LineTransport {
245
262
  return;
246
263
  }
247
264
  if (frame.type === 'result' && this.turnWaiter) {
248
- const waiter = this.turnWaiter;
249
- this.turnWaiter = null;
250
- frame.is_error
251
- ? waiter.reject(new Error(frame.result || frame.subtype || 'Claude turn failed.'))
252
- : waiter.resolve(frame);
265
+ if (frame.origin?.kind === 'task-notification') {
266
+ if (this.heldResult)
267
+ clearTimeout(this.heldResult.timer);
268
+ const timer = setTimeout(() => {
269
+ if (this.heldResult?.frame !== frame)
270
+ return;
271
+ this.heldResult = null;
272
+ this.settleTurn(frame);
273
+ }, CLAUDE_NOTIFICATION_RESULT_GRACE_MS);
274
+ timer.unref?.();
275
+ this.heldResult = { frame, timer };
276
+ return;
277
+ }
278
+ this.settleTurn(frame);
253
279
  return;
254
280
  }
255
281
  if (frame.type === 'control_response') {
@@ -266,7 +292,20 @@ class ClaudeTransport extends LineTransport {
266
292
  if (frame.type === 'control_request')
267
293
  void this.answerReverse(frame);
268
294
  }
295
+ settleTurn(frame) {
296
+ const waiter = this.turnWaiter;
297
+ if (!waiter)
298
+ return;
299
+ this.turnWaiter = null;
300
+ frame.is_error
301
+ ? waiter.reject(new Error(frame.result || frame.subtype || 'Claude turn failed.'))
302
+ : waiter.resolve(frame);
303
+ }
269
304
  onClose(error) {
305
+ if (this.heldResult) {
306
+ clearTimeout(this.heldResult.timer);
307
+ this.heldResult = null;
308
+ }
270
309
  if (this.turnWaiter) {
271
310
  this.turnWaiter.reject(error);
272
311
  this.turnWaiter = null;
@@ -330,6 +369,28 @@ class ClaudeTransport extends LineTransport {
330
369
  timer.unref?.();
331
370
  return waiter.promise.finally(() => clearTimeout(timer));
332
371
  }
372
+ /**
373
+ * Claude Code folds a stdin `user` frame that arrives during a turn into that
374
+ * turn: the model reads it after the tool calls in flight finish, within the
375
+ * same `result`, and the transcript records it as a `queued_command`
376
+ * attachment rather than a new user row (verified on 2.1.280). `priority`
377
+ * selects what happens to the running work. `next` is the fold described
378
+ * above and matches what the interactive TUI does when the user types while
379
+ * Claude works. `now` would end the running turn with `aborted_tools` and
380
+ * answer the message as a fresh turn, which is the interrupt fallback the
381
+ * queue already offers, so it is not used here.
382
+ */
383
+ steer(text, _messageId = '') {
384
+ if (!this.turnWaiter)
385
+ throw new Error('Claude has no active turn to steer.');
386
+ this.emit({
387
+ type: 'user',
388
+ session_id: this.sessionId,
389
+ message: { role: 'user', content: [{ type: 'text', text }] },
390
+ parent_tool_use_id: null,
391
+ priority: 'next',
392
+ });
393
+ }
333
394
  async interrupt() {
334
395
  try {
335
396
  await this.controlRequest('interrupt');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@turns/dev",
3
- "version": "0.0.17",
3
+ "version": "0.0.19",
4
4
  "description": "Upload local AI agent sessions to turns.dev",
5
5
  "license": "MIT",
6
6
  "type": "module",