agent-workbench 0.4.0 → 0.4.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/README.md CHANGED
@@ -31,7 +31,7 @@ the app touching it.
31
31
  | **Tabs** | Several live sessions at once, from any of the CLIs, each in its own directory. They survive an `F5`: the processes live on the server, not in the browser tab. Each tab's dot tells you whether the agent is working, idle or waiting for an answer, on the CLIs that publish their status ([below](#multiple-clis)). |
32
32
  | **Zero-cost startup** | When you open the app, tabs come back as **sleeping tabs**: you can read them in full, and they don't launch any CLI. You open the CLI with a button when you want to write to the agent. |
33
33
  | **History** | Your projects and past conversations in the sidebar, with a filter, and those of all four CLIs together under each project, each with its badge. Opening one resumes it with its CLI, in the same session. **Archive history…** hides a CLI's sessions from before today in one go, and each project can be archived whole with one button: nothing is deleted, and both can be undone. |
34
- | **Conversation** | The active session's messages, live, with tool calls collapsed and their results inside. Search, jump between matches, copy any message or code block and, depending on the CLI, answer the agent's questions from the chat. |
34
+ | **Conversation** | The active session's messages, live, with tool calls collapsed and their results inside. A row at the bottom tells you when your message reached the CLI, whether the agent is working and, with Claude Code, which subagents are still at it after the agent finished its turn. Search, jump between matches and back to the end, copy any message or code block and, depending on the CLI, answer the agent's questions from the chat. |
35
35
  | **Continue with…** | With more than one CLI installed, a conversation can be continued with another CLI in the same folder. The new agent starts from a transcript of the last turns, not from the context the previous one had. |
36
36
  | **Search everything** | With more than one CLI and something saved in the local copy, the sidebar filter also searches the text of every saved conversation, not just their titles. |
37
37
  | **Context meter** | Tokens from the last request against the model's context window. Tokens, never money. |
@@ -383,7 +383,9 @@ The app captures exactly those combinations and no others. `Shift+Tab` only
383
383
  when focus isn't on the terminal, because inside it's the key the CLI uses to
384
384
  switch modes. Everything else —`Esc`, `Esc Esc`, `Ctrl+C`, `Ctrl+R`, `Ctrl+O`,
385
385
  the arrow keys and above all `Alt+V`, which pastes images— reaches the CLI
386
- untouched.
386
+ untouched. `Ctrl+V` too: what it does there is up to the CLI. Only in the
387
+ console at the bottom, on Windows, `Ctrl+V` pastes text, as in Windows
388
+ Terminal.
387
389
 
388
390
  ---
389
391
 
package/dist/server.js CHANGED
@@ -170,6 +170,7 @@ function noticeText(part) {
170
170
  return part.detail.length > 0 ? `La CLI devolvi\xF3 un error: ${part.detail}` : "La CLI devolvi\xF3 un error";
171
171
  }
172
172
  }
173
+ var MAX_SUBAGENT_DESCRIPTION = 120;
173
174
  var EMPTY_CONTEXT_USAGE = {
174
175
  lastRequestTokens: 0,
175
176
  lastOutputTokens: 0,
@@ -738,6 +739,8 @@ function parseClientMessage(raw) {
738
739
  if (files.length > 0) message.files = files;
739
740
  }
740
741
  if (record["send"] === false) message.send = false;
742
+ const requestId = asNonEmptyString(record["requestId"]);
743
+ if (requestId !== null) message.requestId = requestId;
741
744
  return message;
742
745
  }
743
746
  case "agent.mode": {
@@ -5069,6 +5072,135 @@ function toTurnDuration(record) {
5069
5072
  return { eventId, durationMs };
5070
5073
  }
5071
5074
 
5075
+ // packages/server/src/agents/claude-code/subagent-tracker.ts
5076
+ var SUBAGENT_TOOLS = /* @__PURE__ */ new Set(["Agent", "Task"]);
5077
+ var MAX_TRACKED = 50;
5078
+ var NOTIFICATION = /<task-notification>([\s\S]*?)<\/task-notification>/g;
5079
+ var TOOL_USE_ID = /<tool-use-id>\s*([^<\s]+)\s*<\/tool-use-id>/;
5080
+ function labelOf(value) {
5081
+ if (typeof value !== "string") return "";
5082
+ return value.replace(/\s+/g, " ").trim().slice(0, MAX_SUBAGENT_DESCRIPTION);
5083
+ }
5084
+ function notifiedToolUseIds(text) {
5085
+ if (!text.includes("<task-notification>")) return [];
5086
+ const ids = [];
5087
+ for (const match of text.matchAll(NOTIFICATION)) {
5088
+ const id = TOOL_USE_ID.exec(match[1] ?? "")?.[1];
5089
+ if (id !== void 0) ids.push(id);
5090
+ }
5091
+ return ids;
5092
+ }
5093
+ function contentText(content) {
5094
+ if (typeof content === "string") return content;
5095
+ if (!Array.isArray(content)) return "";
5096
+ let text = "";
5097
+ for (const block of content) {
5098
+ const record = recordOf(block);
5099
+ if (record !== null && record["type"] === "text" && typeof record["text"] === "string") {
5100
+ text += record["text"];
5101
+ }
5102
+ }
5103
+ return text;
5104
+ }
5105
+ function launchedInBackground(toolUseResult) {
5106
+ const result = recordOf(toolUseResult);
5107
+ return result !== null && (result["status"] === "async_launched" || result["isAsync"] === true);
5108
+ }
5109
+ var SubagentTracker = class {
5110
+ /** Los que no terminaron, en orden de lanzamiento (`Map` conserva el de insercion). */
5111
+ pending = /* @__PURE__ */ new Map();
5112
+ /** Una linea del JSONL, ya parseada. Nunca lanza: el esquema cambia. */
5113
+ observe(record) {
5114
+ if (record["isSidechain"] === true) return;
5115
+ switch (record["type"]) {
5116
+ case "assistant":
5117
+ this.observeLaunches(record);
5118
+ return;
5119
+ case "user":
5120
+ this.observeResults(record);
5121
+ this.finish(notifiedToolUseIds(contentText(recordOf(record["message"])?.["content"])));
5122
+ return;
5123
+ case "queue-operation":
5124
+ if (record["operation"] === "enqueue" && typeof record["content"] === "string") {
5125
+ this.finish(notifiedToolUseIds(record["content"]));
5126
+ }
5127
+ return;
5128
+ case "attachment": {
5129
+ const attachment = recordOf(record["attachment"]);
5130
+ if (attachment?.["type"] === "queued_command" && typeof attachment["prompt"] === "string") {
5131
+ this.finish(notifiedToolUseIds(attachment["prompt"]));
5132
+ }
5133
+ return;
5134
+ }
5135
+ default:
5136
+ return;
5137
+ }
5138
+ }
5139
+ /**
5140
+ * Los que siguen trabajando y los lanzo el proceso de `launchedAt` o uno
5141
+ * posterior. Uno sin hora no se puede atribuir a ningun proceso y no cuenta.
5142
+ */
5143
+ running(launchedAt) {
5144
+ const result = [];
5145
+ for (const subagent of this.pending.values()) {
5146
+ if (subagent.startedAt > 0 && subagent.startedAt >= launchedAt) result.push({ ...subagent });
5147
+ }
5148
+ return result;
5149
+ }
5150
+ /** El archivo se reemplazo: lo anotado era de otra lectura. */
5151
+ reset() {
5152
+ this.pending.clear();
5153
+ }
5154
+ observeLaunches(record) {
5155
+ const content = recordOf(record["message"])?.["content"];
5156
+ if (!Array.isArray(content)) return;
5157
+ const startedAt = readTimestamp(record["timestamp"]);
5158
+ for (const block of content) {
5159
+ const call = recordOf(block);
5160
+ if (call === null || call["type"] !== "tool_use") continue;
5161
+ const toolUseId = call["id"];
5162
+ if (typeof toolUseId !== "string" || toolUseId.length === 0) continue;
5163
+ if (typeof call["name"] !== "string" || !SUBAGENT_TOOLS.has(call["name"])) continue;
5164
+ if (this.pending.has(toolUseId)) continue;
5165
+ const input = recordOf(call["input"]);
5166
+ this.pending.set(toolUseId, {
5167
+ toolUseId,
5168
+ description: labelOf(input?.["description"]),
5169
+ background: input?.["run_in_background"] === true,
5170
+ startedAt
5171
+ });
5172
+ if (this.pending.size > MAX_TRACKED) {
5173
+ const oldest = this.pending.keys().next();
5174
+ if (!oldest.done) this.pending.delete(oldest.value);
5175
+ }
5176
+ }
5177
+ }
5178
+ /**
5179
+ * El resultado de la llamada. En segundo plano dice que quedo andando, y
5180
+ * sigue; si no, termino —o no llego a lanzarse, que para el hilo es lo mismo—.
5181
+ */
5182
+ observeResults(record) {
5183
+ const content = recordOf(record["message"])?.["content"];
5184
+ if (!Array.isArray(content)) return;
5185
+ for (const block of content) {
5186
+ const result = recordOf(block);
5187
+ if (result === null || result["type"] !== "tool_result") continue;
5188
+ const toolUseId = result["tool_use_id"];
5189
+ if (typeof toolUseId !== "string") continue;
5190
+ const subagent = this.pending.get(toolUseId);
5191
+ if (subagent === void 0) continue;
5192
+ if (result["is_error"] !== true && launchedInBackground(record["toolUseResult"])) {
5193
+ subagent.background = true;
5194
+ } else {
5195
+ this.pending.delete(toolUseId);
5196
+ }
5197
+ }
5198
+ }
5199
+ finish(toolUseIds) {
5200
+ for (const toolUseId of toolUseIds) this.pending.delete(toolUseId);
5201
+ }
5202
+ };
5203
+
5072
5204
  // packages/server/src/agents/claude-code/conversation-follower.ts
5073
5205
  var MAX_ATTACHMENT_LINKS = 100;
5074
5206
  var MAX_PLAN_REFS = 50;
@@ -5155,6 +5287,11 @@ var ConversationFollower = class {
5155
5287
  * ninguna—, y ahi vale con que se lanzo la pestana (`LAUNCH_PERMISSION_MODE`).
5156
5288
  */
5157
5289
  permissionMode = null;
5290
+ /**
5291
+ * Los subagentes que no terminaron (§4.15). Mira lineas que no son tarjetas
5292
+ * —el aviso de fin es una `queue-operation`—, asi que va antes de todo filtro.
5293
+ */
5294
+ subagents = new SubagentTracker();
5158
5295
  /** Los topes de cada parte. Los de transporte salvo que se lea para la copia propia. */
5159
5296
  limits;
5160
5297
  /** La pestana cuyos documentos se reconocen. Ver el constructor. */
@@ -5207,6 +5344,10 @@ var ConversationFollower = class {
5207
5344
  getUsage() {
5208
5345
  return this.usage;
5209
5346
  }
5347
+ /** Los subagentes del proceso de `launchedAt` que siguen trabajando (§4.15). */
5348
+ runningSubagents(launchedAt) {
5349
+ return this.subagents.running(launchedAt);
5350
+ }
5210
5351
  /** Ultimos `limit` eventos, que es lo que se quiere ver al abrir el panel. */
5211
5352
  getTail(limit) {
5212
5353
  return this.jsonl.getTail(limit);
@@ -5252,6 +5393,7 @@ var ConversationFollower = class {
5252
5393
  this.permissionMode = null;
5253
5394
  this.attachmentRoots.clear();
5254
5395
  this.planFiles = [];
5396
+ this.subagents.reset();
5255
5397
  }
5256
5398
  /** true si la linea es la invocacion de `/model` que la CLI guarda. */
5257
5399
  mentionsModelCommand(record) {
@@ -5268,6 +5410,7 @@ var ConversationFollower = class {
5268
5410
  * los que ya guardo, para pegarle a uno anterior la duracion o una imagen.
5269
5411
  */
5270
5412
  consume(record, lineNumber, events) {
5413
+ this.subagents.observe(record);
5271
5414
  if (record["type"] === "cost-state") {
5272
5415
  this.sessionVariants.observeCostState(record, Date.now());
5273
5416
  this.recomputeModel();
@@ -5647,6 +5790,10 @@ var ClaudeCodeSessionFollower = class {
5647
5790
  hasOpenToolCall() {
5648
5791
  return false;
5649
5792
  }
5793
+ /** Los subagentes del proceso de `launchedAt` que siguen trabajando (§4.15). */
5794
+ runningSubagents(launchedAt) {
5795
+ return this.inner.runningSubagents(launchedAt);
5796
+ }
5650
5797
  };
5651
5798
 
5652
5799
  // packages/server/src/agents/claude-code/session-scan.ts
@@ -10168,6 +10315,9 @@ var INITIAL_EVENT_LIMIT = 300;
10168
10315
  var MAX_PAGE_LIMIT = 500;
10169
10316
  var PENDING_QUESTION_LOOKBACK = 50;
10170
10317
  var POLL_DEBOUNCE_MS = 150;
10318
+ function sameSubagents(a, b) {
10319
+ return a.length === b.length && a.every((item, index) => item.toolUseId === b[index]?.toolUseId && item.background === b[index]?.background);
10320
+ }
10171
10321
  var ConversationHub = class extends EventEmitter {
10172
10322
  constructor(registry, agents) {
10173
10323
  super();
@@ -10175,7 +10325,10 @@ var ConversationHub = class extends EventEmitter {
10175
10325
  this.agents = agents;
10176
10326
  registry.on("session", (terminalId) => void this.rebind(terminalId));
10177
10327
  registry.on("changed", () => {
10178
- for (const [terminalId, entry] of this.entries) this.refreshOpenToolCall(terminalId, entry);
10328
+ for (const [terminalId, entry] of this.entries) {
10329
+ this.refreshOpenToolCall(terminalId, entry);
10330
+ this.refreshSubagents(terminalId, entry);
10331
+ }
10179
10332
  });
10180
10333
  }
10181
10334
  registry;
@@ -10199,6 +10352,7 @@ var ConversationHub = class extends EventEmitter {
10199
10352
  usage: { ...EMPTY_CONTEXT_USAGE },
10200
10353
  waitingFor: null,
10201
10354
  openToolCall: false,
10355
+ subagents: [],
10202
10356
  plans: []
10203
10357
  };
10204
10358
  }
@@ -10235,6 +10389,7 @@ var ConversationHub = class extends EventEmitter {
10235
10389
  permissionMode: entry.observedMode ?? entry.assumedMode,
10236
10390
  waitingFor: entry.waitingFor,
10237
10391
  openToolCall: entry.openToolCall,
10392
+ subagents: entry.subagents,
10238
10393
  plans: plans === null ? [] : await plans.describe(
10239
10394
  { cwd: entry.cwd, sessionId: entry.sessionId },
10240
10395
  entry.follower.getPlanFiles()
@@ -10284,6 +10439,7 @@ var ConversationHub = class extends EventEmitter {
10284
10439
  waitingFor: null,
10285
10440
  statusActivity: null,
10286
10441
  openToolCall: false,
10442
+ subagents: [],
10287
10443
  primed: false,
10288
10444
  stopWatchingStatus: null,
10289
10445
  followTicker: null
@@ -10326,6 +10482,23 @@ var ConversationHub = class extends EventEmitter {
10326
10482
  entry.openToolCall = open8;
10327
10483
  this.emit("toolCall", terminalId, open8);
10328
10484
  }
10485
+ /**
10486
+ * Recalcula los subagentes que siguen trabajando y avisa si cambiaron
10487
+ * (§4.15). Solo los del proceso vivo: sin pty no queda ninguno, y los de un
10488
+ * proceso anterior murieron con el sin escribir el aviso de fin. Una CLI cuyo
10489
+ * seguidor no los declara no tiene nunca.
10490
+ */
10491
+ refreshSubagents(terminalId, entry) {
10492
+ const launchedAt = this.registry.launchedAtOf(terminalId);
10493
+ const next = launchedAt === null || entry.follower.runningSubagents === void 0 ? [] : [...entry.follower.runningSubagents(launchedAt)];
10494
+ if (sameSubagents(next, entry.subagents)) return;
10495
+ entry.subagents = next;
10496
+ this.emit("subagents", terminalId, next);
10497
+ }
10498
+ /** Los subagentes que siguen trabajando, lo ultimo avisado. [] si nadie sigue la pestana. */
10499
+ getSubagents(terminalId) {
10500
+ return this.entries.get(terminalId)?.subagents ?? [];
10501
+ }
10329
10502
  /**
10330
10503
  * La pestana cambio de sesion: se suelta el seguidor viejo y se sigue la
10331
10504
  * nueva, sin perder a quien estaba mirando.
@@ -10506,6 +10679,7 @@ var ConversationHub = class extends EventEmitter {
10506
10679
  permissionMode: this.resetMode(entry),
10507
10680
  waitingFor: entry.waitingFor,
10508
10681
  openToolCall: entry.openToolCall,
10682
+ subagents: entry.subagents,
10509
10683
  // Este snapshot es sincronico y describir un plan pide el disco. Quien
10510
10684
  // necesita los planes usa `subscribe`, que si puede esperar.
10511
10685
  plans: []
@@ -10662,6 +10836,7 @@ var ConversationHub = class extends EventEmitter {
10662
10836
  return;
10663
10837
  }
10664
10838
  this.refreshOpenToolCall(terminalId, entry);
10839
+ this.refreshSubagents(terminalId, entry);
10665
10840
  if (options.silent) return;
10666
10841
  const observed = entry.follower.getPermissionMode();
10667
10842
  if (observed !== null) {
@@ -19331,6 +19506,7 @@ function attachTerminalSocket(options) {
19331
19506
  const onConversationMode = (terminalId, mode) => broadcast({ type: "conversation.mode", terminalId, mode });
19332
19507
  const onConversationWaiting = (terminalId, waitingFor) => broadcast({ type: "conversation.waiting", terminalId, waitingFor });
19333
19508
  const onConversationToolCall = (terminalId, open8) => broadcast({ type: "conversation.toolCall", terminalId, open: open8 });
19509
+ const onConversationSubagents = (terminalId, subagents) => broadcast({ type: "conversation.subagents", terminalId, subagents });
19334
19510
  const onConversationPlans = (terminalId, plans) => broadcast({ type: "conversation.plans", terminalId, plans });
19335
19511
  const onConversationTurns = (terminalId, turns) => broadcast({ type: "conversation.turns", terminalId, turns });
19336
19512
  const onConversationParts = (terminalId, updates) => broadcast({ type: "conversation.parts", terminalId, updates });
@@ -19394,7 +19570,8 @@ function attachTerminalSocket(options) {
19394
19570
  waitingFor: snapshot.waitingFor,
19395
19571
  // El de ahora y no el del snapshot: leer la configuracion es asincrono,
19396
19572
  // y un aviso que salio mientras tanto no puede quedar pisado por uno viejo.
19397
- openToolCall: conversations.isToolCallOpen(terminalId)
19573
+ openToolCall: conversations.isToolCallOpen(terminalId),
19574
+ subagents: conversations.getSubagents(terminalId)
19398
19575
  });
19399
19576
  });
19400
19577
  };
@@ -19409,6 +19586,7 @@ function attachTerminalSocket(options) {
19409
19586
  conversations.on("mode", onConversationMode);
19410
19587
  conversations.on("waiting", onConversationWaiting);
19411
19588
  conversations.on("toolCall", onConversationToolCall);
19589
+ conversations.on("subagents", onConversationSubagents);
19412
19590
  conversations.on("turns", onConversationTurns);
19413
19591
  conversations.on("plans", onConversationPlans);
19414
19592
  conversations.on("parts", onConversationParts);
@@ -19578,6 +19756,12 @@ function attachTerminalSocket(options) {
19578
19756
  */
19579
19757
  case "agent.submit": {
19580
19758
  const { terminalId, text: text2, images } = message;
19759
+ let settled = false;
19760
+ const settle = (delivered) => {
19761
+ if (settled || message.requestId === void 0) return;
19762
+ settled = true;
19763
+ send(socket, { type: "agent.submitted", terminalId, requestId: message.requestId, delivered });
19764
+ };
19581
19765
  const submittedOwner = draftOwnerOf(registry.get(terminalId));
19582
19766
  if (submittedOwner !== null) drafts.delete(submittedOwner);
19583
19767
  const files = message.files ?? [];
@@ -19587,6 +19771,7 @@ function attachTerminalSocket(options) {
19587
19771
  "submit-failed",
19588
19772
  serverText("filesPerMessage", { max: MAX_FILES_PER_SUBMIT })
19589
19773
  );
19774
+ settle(false);
19590
19775
  break;
19591
19776
  }
19592
19777
  if (images.length > MAX_IMAGES_PER_SUBMIT) {
@@ -19595,9 +19780,11 @@ function attachTerminalSocket(options) {
19595
19780
  "submit-failed",
19596
19781
  serverText("imagesPerMessage", { max: MAX_IMAGES_PER_SUBMIT })
19597
19782
  );
19783
+ settle(false);
19598
19784
  break;
19599
19785
  }
19600
19786
  if (images.length > 0 && imageStyleFor(socket, terminalId, serverText("imagesUnsupported")) === null) {
19787
+ settle(false);
19601
19788
  break;
19602
19789
  }
19603
19790
  const submitting = adapterOf(terminalId);
@@ -19618,6 +19805,7 @@ function attachTerminalSocket(options) {
19618
19805
  });
19619
19806
  if (refusal2 !== null) {
19620
19807
  sendError(socket, "submit-failed", refusal2);
19808
+ settle(false);
19621
19809
  break;
19622
19810
  }
19623
19811
  }
@@ -19685,6 +19873,7 @@ function attachTerminalSocket(options) {
19685
19873
  approvalGuard,
19686
19874
  { startAfterMs }
19687
19875
  );
19876
+ settle(outcome === "written");
19688
19877
  if (outcome === "interrupted") interrupted();
19689
19878
  if (outcome === "blocked") {
19690
19879
  const waitingNow = blocksWhileWaiting && registry.activityOf(terminalId) === "waiting";
@@ -19696,7 +19885,7 @@ function attachTerminalSocket(options) {
19696
19885
  }
19697
19886
  },
19698
19887
  { onDropped: interrupted }
19699
- );
19888
+ ).then(() => settle(false));
19700
19889
  break;
19701
19890
  }
19702
19891
  /*
@@ -20014,7 +20203,8 @@ function attachTerminalSocket(options) {
20014
20203
  permissionMode: snapshot.permissionMode,
20015
20204
  defaults: await defaultsFor(message.terminalId),
20016
20205
  waitingFor: snapshot.waitingFor,
20017
- openToolCall: conversations.isToolCallOpen(message.terminalId)
20206
+ openToolCall: conversations.isToolCallOpen(message.terminalId),
20207
+ subagents: conversations.getSubagents(message.terminalId)
20018
20208
  });
20019
20209
  if (snapshot.plans.length > 0) {
20020
20210
  send(socket, {
@@ -20680,6 +20870,7 @@ function attachTerminalSocket(options) {
20680
20870
  conversations.off("mode", onConversationMode);
20681
20871
  conversations.off("waiting", onConversationWaiting);
20682
20872
  conversations.off("toolCall", onConversationToolCall);
20873
+ conversations.off("subagents", onConversationSubagents);
20683
20874
  repos.off("status", onGitStatus);
20684
20875
  memory.off("status", onMemoryStatus);
20685
20876
  stopAgentChanges();