@bivy/bivy 0.10.1-staging.243 → 0.10.1-staging.244

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
@@ -385,6 +385,15 @@ function replayPendingInteractions(sessionId) {
385
385
  broadcast({ type: "approval.created", approval: a });
386
386
  }
387
387
  }
388
+ const record = resolveSession(sessionId);
389
+ if (record?.turnAttention) {
390
+ const { trigger, idleMs, at } = record.turnAttention;
391
+ const mins = Math.max(1, Math.round(idleMs / 60_000));
392
+ const message = trigger === "wedged"
393
+ ? `A tool call has run for ${mins} min without making progress. Stop it or keep waiting?`
394
+ : `The agent has been quiet for ${mins} min. Stop it or keep waiting?`;
395
+ broadcast({ type: "session.turn_attention", sessionId, trigger, idleMs, at, message });
396
+ }
388
397
  }
389
398
  function persistApprovalRequest(request, resolvedAt) {
390
399
  metadata.recordApproval({
@@ -1909,7 +1918,17 @@ const RELAY_COMMANDS = {
1909
1918
  const record = resolveSession(msg.sessionId);
1910
1919
  if (!record || !sessionBusy(record))
1911
1920
  return;
1912
- abortSessionRecord(record, ctx.broadcast);
1921
+ if (record.turnAttention)
1922
+ turnWatchdog.resolveTurnAttention(record, "stop");
1923
+ else
1924
+ abortSessionRecord(record, ctx.broadcast);
1925
+ },
1926
+ "session.turn_attention.resolve"(msg) {
1927
+ const record = resolveSession(msg.sessionId);
1928
+ const action = msg.action === "stop" ? "stop" : msg.action === "continue" ? "continue" : undefined;
1929
+ if (!record || !action)
1930
+ return;
1931
+ turnWatchdog.resolveTurnAttention(record, action);
1913
1932
  },
1914
1933
  async "session.command.invoke"(msg, ctx) {
1915
1934
  // Invoke a protocol-mode agent command (AgentCommand.mode === "protocol")
@@ -2064,6 +2083,7 @@ const RELAY_COMMANDS = {
2064
2083
  // (SessionList.tsx's periodic safety-net poll) clobbering the correct
2065
2084
  // needs_action the live session.question broadcast had just set.
2066
2085
  const pendingApproval = rec ? sessionHasPendingApproval(rec) : approvals.list().some((a) => a.sessionId === s.id && a.status === "pending");
2086
+ const needsAction = pendingApproval || Boolean(rec?.turnAttention);
2067
2087
  return {
2068
2088
  path: s.path,
2069
2089
  id: s.id,
@@ -2079,10 +2099,10 @@ const RELAY_COMMANDS = {
2079
2099
  sandbox: rec?.sandbox ?? normalizeSandboxTier(meta?.sandbox),
2080
2100
  prUrl: rec?.prUrl ?? meta?.prUrl,
2081
2101
  prs: rec?.prs ?? meta?.prs,
2082
- status: pendingApproval ? "needs_action" : (rec ? sessionState(rec).displayStatus : "saved"),
2102
+ status: needsAction ? "needs_action" : (rec ? sessionState(rec).displayStatus : "saved"),
2083
2103
  sessionState: rec ? sessionState(rec) : undefined,
2084
2104
  open: Boolean(rec),
2085
- needsAction: pendingApproval,
2105
+ needsAction,
2086
2106
  bivySession: bivySessionEnvelopeFromSummary(s, rec, meta),
2087
2107
  };
2088
2108
  });
@@ -5873,6 +5893,7 @@ function sessionState(record) {
5873
5893
  awaitingInput: sessionHasPendingApproval(record),
5874
5894
  workspace: record.workspaceState ?? "clean",
5875
5895
  lastTurnFailed: Boolean(record.lastFailureAt),
5896
+ turnNeedsAttention: Boolean(record.turnAttention),
5876
5897
  });
5877
5898
  }
5878
5899
  /** Push axis-only transitions (checkpointing, question/approval changes) that
@@ -6627,11 +6648,25 @@ const stallSweepMs = stallSweepBasis.length ? Math.max(15_000, Math.min(60_000,
6627
6648
  // The stall/timeout orchestration lives in ./session/turn-watchdog-runtime; its
6628
6649
  // whole coupling surface to the daemon is this deps object. The narrow
6629
6650
  // WatchdogSession it operates on is structurally satisfied by SessionRecord.
6651
+ const stallAction = process.env.BIVY_TURN_STALL_ACTION?.trim().toLowerCase() === "recover" ? "recover" : "notify";
6630
6652
  const turnWatchdog = createTurnWatchdog({
6631
6653
  turnTimeoutMs,
6632
6654
  turnStallMs,
6633
6655
  turnActivityStallMs,
6656
+ stallAction,
6634
6657
  broadcast,
6658
+ broadcastSessionState: (record) => broadcastSessionState(record),
6659
+ notifyTurnAttention: (record, message) => {
6660
+ const session = record;
6661
+ void sendNotificationHint({
6662
+ kind: "agent_stalled",
6663
+ sessionId: session.id,
6664
+ targetSessionId: session.id,
6665
+ attentionId: session.id,
6666
+ title: `${sessionNotifyLabel(session)} may be stuck`,
6667
+ body: message,
6668
+ });
6669
+ },
6635
6670
  markSessionFailed: (id) => metadata.touchSession(id, "failed"),
6636
6671
  abortSessionRecord: (record) => abortSessionRecord(record),
6637
6672
  evaluateEphemeralTeardown,
@@ -6662,8 +6697,10 @@ function markSessionWorking(record, activity, opts) {
6662
6697
  // separately anchors the wedged band. Raw subprocess output (tool_execution_
6663
6698
  // update) bumps lastProgressAt above but NOT this, so a chatty-but-hung tool
6664
6699
  // still trips the wedged watchdog. Non-event callers default to structural.
6665
- if (opts?.structural !== false)
6700
+ const structural = opts?.structural !== false;
6701
+ if (structural)
6666
6702
  record.lastStructuralProgressAt = now;
6703
+ turnWatchdog.clearTurnAttentionOnProgress(record, structural);
6667
6704
  // A new attempt resolves the prior turn's failure condition at its source.
6668
6705
  record.lastFailureAt = undefined;
6669
6706
  metadata.touchSession(record.id, "working");
@@ -6673,6 +6710,7 @@ function markSessionWorking(record, activity, opts) {
6673
6710
  }
6674
6711
  }
6675
6712
  function clearSessionWorking(record, forcedStatus) {
6713
+ turnWatchdog.clearTurnAttentionOnProgress(record, true);
6676
6714
  turnWatchdog.clearTurnWatchdog(record);
6677
6715
  touchSession(record);
6678
6716
  record.isWorking = false;
@@ -10188,8 +10226,27 @@ app.post("/api/session/abort", (req, res, next) => {
10188
10226
  const record = resolveSession(req.body?.sessionId);
10189
10227
  if (!record)
10190
10228
  return res.status(404).json({ error: "No active session" });
10191
- if (sessionBusy(record))
10192
- abortSessionRecord(record);
10229
+ if (sessionBusy(record)) {
10230
+ if (record.turnAttention)
10231
+ turnWatchdog.resolveTurnAttention(record, "stop");
10232
+ else
10233
+ abortSessionRecord(record);
10234
+ }
10235
+ res.json({ ok: true });
10236
+ }
10237
+ catch (error) {
10238
+ next(error);
10239
+ }
10240
+ });
10241
+ app.post("/api/session/turn-attention", (req, res, next) => {
10242
+ try {
10243
+ const record = resolveSession(req.body?.sessionId);
10244
+ if (!record)
10245
+ return res.status(404).json({ error: "No active session" });
10246
+ const action = req.body?.action === "stop" ? "stop" : req.body?.action === "continue" ? "continue" : undefined;
10247
+ if (!action)
10248
+ return res.status(400).json({ error: "Action must be stop or continue" });
10249
+ turnWatchdog.resolveTurnAttention(record, action);
10193
10250
  res.json({ ok: true });
10194
10251
  }
10195
10252
  catch (error) {
@@ -22,6 +22,10 @@ export function deriveSessionState(evidence) {
22
22
  displayStatus = "needs_attention";
23
23
  else if (process === "exited")
24
24
  displayStatus = "failed";
25
+ // A working turn the watchdog flagged for review is actionable even though the
26
+ // agent is still technically "working" — surface it so the user sees the card.
27
+ else if (agent === "working" && evidence.turnNeedsAttention)
28
+ displayStatus = "needs_attention";
25
29
  else if (agent === "working")
26
30
  displayStatus = "working";
27
31
  else if (evidence.lastTurnFailed)
@@ -63,6 +63,12 @@ export function createTurnWatchdog(deps) {
63
63
  return `A tool call ran for ${mins} min without making progress and was recovered. Send a message to continue.`;
64
64
  return `The agent stopped responding (no activity for ${mins} min) and was recovered. Send a message to continue.`;
65
65
  }
66
+ function turnAttentionMessage(idleMs, trigger) {
67
+ const mins = Math.max(1, Math.round(idleMs / 60_000));
68
+ if (trigger === "wedged")
69
+ return `A tool call has run for ${mins} min without making progress. Stop it or keep waiting?`;
70
+ return `The agent has been quiet for ${mins} min. Stop it or keep waiting?`;
71
+ }
66
72
  /** Idle used for a stall's diagnostic/message. A `wedged` turn is measured from
67
73
  * the last STRUCTURAL progress (raw output kept flowing), every other trigger
68
74
  * from the last progress of any kind. */
@@ -97,6 +103,10 @@ export function createTurnWatchdog(deps) {
97
103
  if (record.turnTimedOut)
98
104
  return; // already recovering this turn
99
105
  record.turnTimedOut = true;
106
+ // A real recovery (user hit Stop, wall-clock cap, or a dead subprocess)
107
+ // supersedes any pending "needs your decision" card — drop it so the client
108
+ // closes it and the failed/outcome events below take over.
109
+ record.turnAttention = undefined;
100
110
  record.lastFailureAt = now();
101
111
  const trigger = diag?.trigger ?? "stalled";
102
112
  const turnMs = record.workingStartedAt ? record.lastFailureAt - record.workingStartedAt : undefined;
@@ -152,9 +162,53 @@ export function createTurnWatchdog(deps) {
152
162
  activityStallMs: turnActivityStallMs,
153
163
  });
154
164
  }
155
- /** Periodic sweep: recover any working session whose turn has stalled. This is
156
- * what makes a hang self-heal without the user hitting Stop or waiting out the
157
- * hour-long cap. */
165
+ function clearTurnAttention(record) {
166
+ if (!record.turnAttention)
167
+ return;
168
+ record.turnAttention = undefined;
169
+ deps.broadcast({ type: "session.turn_attention.resolved", sessionId: record.id });
170
+ deps.broadcastSessionState(record);
171
+ }
172
+ function flagTurnForReview(record, trigger, idleMs, at) {
173
+ if (record.turnAttention)
174
+ return;
175
+ const message = turnAttentionMessage(idleMs, trigger);
176
+ record.turnAttention = { trigger, idleMs, at };
177
+ deps.broadcast({ type: "session.turn_attention", sessionId: record.id, trigger, idleMs, at, message });
178
+ deps.broadcastSessionState(record);
179
+ deps.notifyTurnAttention(record, message);
180
+ }
181
+ function resolveTurnAttention(record, action) {
182
+ const attention = record.turnAttention;
183
+ if (!attention)
184
+ return;
185
+ if (action === "stop") {
186
+ recoverStuckTurn(record, turnStallMessage(attention.idleMs, attention.trigger), attention);
187
+ // recoverStuckTurn clears the field, but it intentionally emits failure
188
+ // frames rather than the ordinary resolved frame. Emit this one too so a
189
+ // client can deterministically remove its card before those frames arrive.
190
+ deps.broadcast({ type: "session.turn_attention.resolved", sessionId: record.id });
191
+ deps.broadcastSessionState(record);
192
+ return;
193
+ }
194
+ const at = now();
195
+ record.lastProgressAt = at;
196
+ record.lastStructuralProgressAt = at;
197
+ clearTurnAttention(record);
198
+ }
199
+ function clearTurnAttentionOnProgress(record, structural) {
200
+ const attention = record.turnAttention;
201
+ if (!attention)
202
+ return;
203
+ // Silence means any activity disproves the warning. A wedged tool can keep
204
+ // producing raw output forever, so only structural progress dismisses it.
205
+ if (attention.trigger === "wedged" && !structural)
206
+ return;
207
+ clearTurnAttention(record);
208
+ }
209
+ /** Periodic sweep: dead subprocesses are always recovered. Time-based soft
210
+ * stalls normally ask the user instead; operators can opt into the legacy
211
+ * automatic recovery with stallAction="recover". */
158
212
  function sweepStalledTurns() {
159
213
  if (turnStallMs <= 0 && turnActivityStallMs <= 0)
160
214
  return;
@@ -164,7 +218,12 @@ export function createTurnWatchdog(deps) {
164
218
  if (!trigger)
165
219
  continue;
166
220
  const idleMs = stallIdleMs(record, trigger, at);
167
- recoverStuckTurn(record, turnStallMessage(idleMs, trigger), { trigger, idleMs });
221
+ if (trigger === "pid_dead" || deps.stallAction === "recover") {
222
+ recoverStuckTurn(record, turnStallMessage(idleMs, trigger), { trigger, idleMs });
223
+ }
224
+ else {
225
+ flagTurnForReview(record, trigger, idleMs, at);
226
+ }
168
227
  }
169
228
  }
170
229
  /**
@@ -216,5 +275,7 @@ export function createTurnWatchdog(deps) {
216
275
  stallTriggerFor,
217
276
  sweepStalledTurns,
218
277
  turnRecoveryStats,
278
+ resolveTurnAttention,
279
+ clearTurnAttentionOnProgress,
219
280
  };
220
281
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.10.1-staging.243",
3
+ "version": "0.10.1-staging.244",
4
4
  "type": "module",
5
5
  "license": "AGPL-3.0-only",
6
6
  "description": "Run coding agents on machines you own. Open-source, self-hostable agent workspace.",