@timqi/pier 0.0.29 → 0.1.0

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.
Files changed (94) hide show
  1. package/dist/agent/config.js +22 -0
  2. package/dist/agent/events.js +4 -1
  3. package/dist/agent/listing.js +114 -16
  4. package/dist/agent/pi.js +158 -16
  5. package/dist/channels/lark-panel.js +1 -1
  6. package/dist/core/inbox.js +1 -1
  7. package/dist/core/router.js +4 -0
  8. package/dist/core/types.js +13 -1
  9. package/dist/db.js +30 -0
  10. package/dist/drain.js +1 -1
  11. package/dist/extensions/web/content.js +2 -2
  12. package/dist/main.js +8 -1
  13. package/dist/secrets.js +1 -1
  14. package/dist/service.js +2 -2
  15. package/dist/settings.js +31 -10
  16. package/dist/tasks/agent.js +109 -73
  17. package/dist/tasks/callbacks.js +1 -3
  18. package/dist/tasks/command.js +27 -8
  19. package/dist/tasks/execution.js +6 -4
  20. package/dist/tasks/groups.js +35 -26
  21. package/dist/tasks/messages.js +33 -27
  22. package/dist/tasks/outbox.js +48 -21
  23. package/dist/tasks/routes.js +1 -4
  24. package/dist/tasks/runs.js +10 -4
  25. package/dist/tasks/service.js +24 -12
  26. package/dist/tasks/store.js +17 -3
  27. package/dist/tasks/tool.js +97 -25
  28. package/dist/tools.js +2 -2
  29. package/dist/update.js +1 -1
  30. package/dist/web/instance.js +10 -3
  31. package/dist/web/providers.js +9 -2
  32. package/dist/web/public/assets/{activity-D3m4L2IL.js → activity-Bl3vZukb.js} +2 -2
  33. package/dist/web/public/assets/activity-Bl3vZukb.js.br +0 -0
  34. package/dist/web/public/assets/activity-Bl3vZukb.js.gz +0 -0
  35. package/dist/web/public/assets/boards-DYuf4Mlj.js +1 -0
  36. package/dist/web/public/assets/boards-DYuf4Mlj.js.br +0 -0
  37. package/dist/web/public/assets/boards-DYuf4Mlj.js.gz +0 -0
  38. package/dist/web/public/assets/explorer-qJH_9nTE.js +4 -0
  39. package/dist/web/public/assets/explorer-qJH_9nTE.js.br +0 -0
  40. package/dist/web/public/assets/explorer-qJH_9nTE.js.gz +0 -0
  41. package/dist/web/public/assets/index-Dqdb-Eqt.js +85 -0
  42. package/dist/web/public/assets/index-Dqdb-Eqt.js.br +0 -0
  43. package/dist/web/public/assets/index-Dqdb-Eqt.js.gz +0 -0
  44. package/dist/web/public/assets/index-DzmMzvi_.css +2 -0
  45. package/dist/web/public/assets/index-DzmMzvi_.css.br +0 -0
  46. package/dist/web/public/assets/index-DzmMzvi_.css.gz +0 -0
  47. package/dist/web/public/assets/runs-BLJu7EXN.js +1 -0
  48. package/dist/web/public/assets/runs-BLJu7EXN.js.br +0 -0
  49. package/dist/web/public/assets/runs-BLJu7EXN.js.gz +0 -0
  50. package/dist/web/public/assets/settings-BrdVh-Zi.js +5 -0
  51. package/dist/web/public/assets/settings-BrdVh-Zi.js.br +0 -0
  52. package/dist/web/public/assets/settings-BrdVh-Zi.js.gz +0 -0
  53. package/dist/web/public/assets/task-runs-CeQS1rxa.js +3 -0
  54. package/dist/web/public/assets/task-runs-CeQS1rxa.js.br +0 -0
  55. package/dist/web/public/assets/task-runs-CeQS1rxa.js.gz +0 -0
  56. package/dist/web/public/assets/tasks-bcb3fYdK.js +4 -0
  57. package/dist/web/public/assets/tasks-bcb3fYdK.js.br +0 -0
  58. package/dist/web/public/assets/tasks-bcb3fYdK.js.gz +0 -0
  59. package/dist/web/public/index.html +76 -120
  60. package/dist/web/public/index.html.br +0 -0
  61. package/dist/web/public/index.html.gz +0 -0
  62. package/dist/web/public/manifest.webmanifest +2 -2
  63. package/dist/web/public/manifest.webmanifest.br +0 -0
  64. package/dist/web/public/manifest.webmanifest.gz +0 -0
  65. package/dist/web/server.js +40 -8
  66. package/dist/web/session-state.js +5 -5
  67. package/package.json +2 -1
  68. package/skills/pier-tasks/SKILL.md +145 -160
  69. package/dist/web/public/assets/activity-D3m4L2IL.js.br +0 -0
  70. package/dist/web/public/assets/activity-D3m4L2IL.js.gz +0 -0
  71. package/dist/web/public/assets/boards-BIObcQeX.js +0 -1
  72. package/dist/web/public/assets/boards-BIObcQeX.js.br +0 -0
  73. package/dist/web/public/assets/boards-BIObcQeX.js.gz +0 -0
  74. package/dist/web/public/assets/explorer-C_rSWPNB.js +0 -4
  75. package/dist/web/public/assets/explorer-C_rSWPNB.js.br +0 -0
  76. package/dist/web/public/assets/explorer-C_rSWPNB.js.gz +0 -0
  77. package/dist/web/public/assets/index-CX3fYZY5.css +0 -2
  78. package/dist/web/public/assets/index-CX3fYZY5.css.br +0 -0
  79. package/dist/web/public/assets/index-CX3fYZY5.css.gz +0 -0
  80. package/dist/web/public/assets/index-uFsZkKOQ.js +0 -85
  81. package/dist/web/public/assets/index-uFsZkKOQ.js.br +0 -0
  82. package/dist/web/public/assets/index-uFsZkKOQ.js.gz +0 -0
  83. package/dist/web/public/assets/runs-Ch6DZq6O.js +0 -1
  84. package/dist/web/public/assets/runs-Ch6DZq6O.js.br +0 -0
  85. package/dist/web/public/assets/runs-Ch6DZq6O.js.gz +0 -0
  86. package/dist/web/public/assets/settings-BWcEIEcv.js +0 -5
  87. package/dist/web/public/assets/settings-BWcEIEcv.js.br +0 -0
  88. package/dist/web/public/assets/settings-BWcEIEcv.js.gz +0 -0
  89. package/dist/web/public/assets/task-runs-DPkwv2UE.js +0 -3
  90. package/dist/web/public/assets/task-runs-DPkwv2UE.js.br +0 -0
  91. package/dist/web/public/assets/task-runs-DPkwv2UE.js.gz +0 -0
  92. package/dist/web/public/assets/tasks-DTiCi2mH.js +0 -4
  93. package/dist/web/public/assets/tasks-DTiCi2mH.js.br +0 -0
  94. package/dist/web/public/assets/tasks-DTiCi2mH.js.gz +0 -0
package/dist/db.js CHANGED
@@ -349,6 +349,36 @@ const MIGRATIONS = [
349
349
  ORDER BY sort, session_id LIMIT -1 OFFSET 8
350
350
  );
351
351
  ALTER TABLE session_state DROP COLUMN pinned;
352
+ `,
353
+ // 21 — unread is the workbench's own attention, so it is only its own rows.
354
+ `
355
+ -- The flag was written for every session whose turn ended, including the
356
+ -- ones no browser is the reader of: an IM session answers its chat, a run's
357
+ -- session answers its supervisor. Both are now skipped at the write
358
+ -- (web/server.ts), and neither could ever be acked — that needs the session
359
+ -- on screen — so the rows they left would stay set forever. On the instance
360
+ -- this was decided on, 195 of 196 marks were those. Cleared wholesale rather
361
+ -- than by owner: the one real row is a turn from before an upgrade nobody
362
+ -- was watching for, and a false amber dot costs less than the join.
363
+ UPDATE session_state SET unread = 0;
364
+ `,
365
+ // 22 — the palette finds what was said, not only what a session is called.
366
+ `
367
+ -- One row per user message and per assistant reply, keyed by the transcript
368
+ -- it came from so a file that is rewritten or gone drops its rows in one
369
+ -- statement (agent/listing.ts). Trigram tokens: a substring match with no
370
+ -- word segmentation, which CJK text has none of and a path or an identifier
371
+ -- in a prompt has too much of. Steps stay out — tool calls, their output,
372
+ -- thinking are the bulk of a transcript and nobody searches for what ran.
373
+ CREATE VIRTUAL TABLE session_fts USING fts5(
374
+ text, session_id UNINDEXED, path UNINDEXED, role UNINDEXED, at UNINDEXED,
375
+ tokenize = 'trigram'
376
+ );
377
+ -- Derived and disposable (migration 7): every row goes, so the next scan
378
+ -- reads every transcript from its first byte and fills the table above.
379
+ -- Resetting parsed_bytes alone would not — a row whose (size, mtime) still
380
+ -- match is trusted without opening the file.
381
+ DELETE FROM session_index;
352
382
  `,
353
383
  ];
354
384
  /**
package/dist/drain.js CHANGED
@@ -11,7 +11,7 @@ import { logger } from "./log.js";
11
11
  const log = logger("drain");
12
12
  /** How long running turns may take before they are aborted. Generous: a turn
13
13
  * can be a subagent fan-out, and an abort still persists the partial work. */
14
- export const DRAIN_DEADLINE_MS = 5 * 60_000;
14
+ const DRAIN_DEADLINE_MS = 5 * 60_000;
15
15
  const POLL_MS = 1_000;
16
16
  /** Shared cleanup window after the deadline. All sessions use the same clock,
17
17
  * so N hung seams still cost at most this long rather than N times as long. */
@@ -49,7 +49,7 @@ export function sourcesFrom(content) {
49
49
  return [...sources.values()].map(({ title, url }) => ({ title, url }));
50
50
  }
51
51
  /** Only what the search itself returned, in the order it ranked them. */
52
- export function searchResultsFrom(content) {
52
+ function searchResultsFrom(content) {
53
53
  const results = new Map();
54
54
  for (const block of content) {
55
55
  if (!isObject(block) || block.type !== "web_search_tool_result")
@@ -61,7 +61,7 @@ export function searchResultsFrom(content) {
61
61
  }
62
62
  return [...results.values()];
63
63
  }
64
- export function searchQueriesFrom(content) {
64
+ function searchQueriesFrom(content) {
65
65
  const queries = [];
66
66
  for (const block of content) {
67
67
  if (!isObject(block) || block.type !== "server_tool_use" || block.name !== "web_search") {
package/dist/main.js CHANGED
@@ -8,6 +8,7 @@ import { normalizeAgentSnapshot } from "./agent/config-sync.js";
8
8
  import { ConfigSync } from "./config-sync.js";
9
9
  import { configSyncTask } from "./config-sync-task.js";
10
10
  import { CredentialStore } from "./agent/credentials.js";
11
+ import { IndexedListing } from "./agent/listing.js";
11
12
  import { PiAgentFactory } from "./agent/pi.js";
12
13
  import { defaultBoardsDir, registerBoardRoutes } from "./boards/boards.js";
13
14
  import { ChannelStore } from "./channels/config.js";
@@ -20,6 +21,7 @@ import { SlackDirectory } from "./channels/slack-directory.js";
20
21
  import { handleSlackTool, slackToolAvailable, slackToolSpec } from "./channels/slack-tool.js";
21
22
  import { parseConversation as parseSlackConversation } from "./channels/slack.js";
22
23
  import { EventHub } from "./core/hub.js";
24
+ import { splitSpeaker } from "./core/identity.js";
23
25
  import { pierDb } from "./db.js";
24
26
  import { deliverLedger, drainForRestart, RestartLedger } from "./drain.js";
25
27
  import { bundledInfo } from "./extensions/index.js";
@@ -135,7 +137,12 @@ new CredentialStore(db, secrets), piConfig,
135
137
  () => settings.get().modelMenu,
136
138
  // Bundled extensions the Console switched on; read per session open, so the
137
139
  // toggle reaches the next session the same way an edited agent file does.
138
- () => settings.get().extensions);
140
+ () => settings.get().extensions,
141
+ // The title model, if the operator picked one; read when a first turn ends.
142
+ () => settings.get().titleModel,
143
+ // The search index reads Pi's transcripts, which carry the speaker header
144
+ // core wrote for the model; this is where the two areas meet.
145
+ new IndexedListing(undefined, undefined, (text) => splitSpeaker(text).text));
139
146
  const hub = new EventHub();
140
147
  const router = new Router(hub, (key) => {
141
148
  // Web conversation ids ARE session ids; an IM conversation id is a chat or a
package/dist/secrets.js CHANGED
@@ -172,7 +172,7 @@ function open(key, sealed, aad) {
172
172
  }
173
173
  /** The real vt CLI. Absent binary or denied approval both surface as the
174
174
  * spawn/exit error — unlock() records it and the operator reads it. */
175
- export const vtCli = {
175
+ const vtCli = {
176
176
  read: (record) => run("vt", ["read", record]),
177
177
  create: async (plaintext) => {
178
178
  const out = await run("vt", ["create"], plaintext);
package/dist/service.js CHANGED
@@ -15,7 +15,7 @@ import { existsSync, mkdirSync, readFileSync, rmdirSync, rmSync, writeFileSync }
15
15
  import { homedir, userInfo } from "node:os";
16
16
  import { dirname, join } from "node:path";
17
17
  export const UNIT_NAME = "pier.service";
18
- export const UPDATE_UNIT_NAME = "pier-update.service";
18
+ const UPDATE_UNIT_NAME = "pier-update.service";
19
19
  /** `~/.config/systemd/user/pier.service` — where a user unit belongs. */
20
20
  export const unitPath = (home = homedir()) => join(home, ".config", "systemd", "user", UNIT_NAME);
21
21
  /** The drop-in Pier writes once and never touches again: what the unit may
@@ -146,7 +146,7 @@ ExecStopPost=systemctl --user start ${UNIT_NAME}
146
146
  * exists to protect the OS and sshd outside it. Written commented so the
147
147
  * operator tuning it can see what each line buys.
148
148
  */
149
- export function renderLimits() {
149
+ function renderLimits() {
150
150
  return `[Service]
151
151
  # Soft ceiling: past this the kernel reclaims hard and lets the unit crawl
152
152
  # instead of killing anything. This is the one that should bite first.
package/dist/settings.js CHANGED
@@ -50,27 +50,40 @@ export function normalizeModelMenu(raw) {
50
50
  return null;
51
51
  const menu = [];
52
52
  for (const item of raw) {
53
- if (typeof item !== "object" || item === null)
53
+ const ref = normalizeModelRef(item);
54
+ if (!ref)
54
55
  return null;
55
- const { provider, id, thinking, note } = item;
56
- if (typeof provider !== "string" || !provider.trim())
57
- return null;
58
- if (typeof id !== "string" || !id.trim())
59
- return null;
60
- if (thinking !== undefined && !isThinkingLevel(thinking))
56
+ const { thinking, note } = item;
57
+ // The one thing repaired rather than rejected, because it is not input:
58
+ // entries stored (or exported by an instance) before the level was
59
+ // required have none, and dropping the menu over it would lose the pins.
60
+ const level = thinking === undefined ? "medium" : thinking;
61
+ if (!isThinkingLevel(level))
61
62
  return null;
62
63
  if (note !== undefined && typeof note !== "string")
63
64
  return null;
64
65
  const cleaned = note?.trim().slice(0, 200);
65
66
  menu.push({
66
- provider: provider.trim(),
67
- id: id.trim(),
68
- ...(thinking !== undefined ? { thinking } : {}),
67
+ ...ref,
68
+ thinking: level,
69
69
  ...(cleaned ? { note: cleaned } : {}),
70
70
  });
71
71
  }
72
72
  return menu;
73
73
  }
74
+ /** One model reference, rejecting rather than repairing — the menu above is
75
+ * built from these. Existence is not checked here: the catalog is the
76
+ * agent's, and a model that went away is reported by the call that fails. */
77
+ export function normalizeModelRef(raw) {
78
+ if (typeof raw !== "object" || raw === null)
79
+ return null;
80
+ const { provider, id } = raw;
81
+ if (typeof provider !== "string" || !provider.trim())
82
+ return null;
83
+ if (typeof id !== "string" || !id.trim())
84
+ return null;
85
+ return { provider: provider.trim(), id: id.trim() };
86
+ }
74
87
  /**
75
88
  * Shape only — an unknown name is not an error here. This file must not know
76
89
  * what Pier bundles (that catalog is code, and importing it would drag the Pi
@@ -107,9 +120,11 @@ export class SettingsStore {
107
120
  this.#db = db;
108
121
  }
109
122
  get() {
123
+ const titleModel = this.#json("titleModel", normalizeModelRef, "a {provider, id}");
110
124
  return {
111
125
  publicUrl: this.#value("publicUrl") ?? "",
112
126
  modelMenu: this.#json("modelMenu", normalizeModelMenu, "a valid menu") ?? [],
127
+ ...(titleModel ? { titleModel } : {}),
113
128
  autoUpdate: this.#value("autoUpdate") === "1",
114
129
  extensions: this.#json("extensions", normalizeExtensions, "a list of names") ?? [],
115
130
  tools: this.#json("tools", normalizeTools, "a list of names") ?? [],
@@ -152,6 +167,12 @@ export class SettingsStore {
152
167
  this.#set("modelMenu", JSON.stringify(menu));
153
168
  return this.get();
154
169
  }
170
+ /** Same contract: hand this `normalizeModelRef`'s output; null switches
171
+ * auto-titling off. */
172
+ setTitleModel(ref) {
173
+ this.#set("titleModel", ref ? JSON.stringify(ref) : "");
174
+ return this.get();
175
+ }
155
176
  setAutoUpdate(on) {
156
177
  this.#set("autoUpdate", on ? "1" : "0");
157
178
  return this.get();
@@ -4,11 +4,10 @@
4
4
  // agents specifically — a bash run costs a process, an agent run costs a
5
5
  // model's context and someone's rate limit.
6
6
  import { quietLabel, splitReply } from "../core/reply.js";
7
- import { Router } from "../core/router.js";
7
+ import { logger } from "../log.js";
8
8
  import { runSource } from "./callbacks.js";
9
- import { TaskMessenger } from "./messages.js";
10
- import { TaskStore } from "./store.js";
11
9
  const MAX_ACTIVE_AGENTS = 4;
10
+ const log = logger("tasks");
12
11
  /** What a child cannot know unless told. Every session gets the chat-surface
13
12
  * contract (<pier>/AGENTS.md), task runs included — so the delegation prompt
14
13
  * says which of it does not apply here, and a supervised run how to reach the
@@ -44,11 +43,18 @@ export class AgentTaskRunner {
44
43
  this.changed = changed;
45
44
  }
46
45
  async execute(run, action, signal, start) {
47
- await this.acquireSlot(run, signal);
48
- try {
49
- const session = await this.resolveSession(run, action);
50
- return await this.withSession(session.id, async () => {
46
+ // Reserve reuse order before resolving the session: a slow resume must not
47
+ // let a later run pass it. Fresh runs each have their own queue key.
48
+ return this.withSession(run.targetSessionId ?? run.id, signal, async () => {
49
+ const reused = run.targetSessionId ? await this.resolveSession(run, action, signal) : undefined;
50
+ if (reused)
51
+ await this.waitUntilIdle(reused, signal);
52
+ await this.acquireSlot(run, signal);
53
+ try {
54
+ const session = reused ?? await this.resolveSession(run, action, signal);
55
+ // A reused session may have become busy while we waited for a slot.
51
56
  await this.waitUntilIdle(session, signal);
57
+ signal.throwIfAborted();
52
58
  // Task requests come seconds apart — the 1h Anthropic cache-write premium
53
59
  // never earns its 2× back, so task runs use the 5m TTL. Set here, not in
54
60
  // resolveSession: this covers create, reuse and resume alike, on
@@ -85,22 +91,16 @@ export class AgentTaskRunner {
85
91
  failure = event.error;
86
92
  }
87
93
  });
88
- // The race below also settles this attempt if Pi ignores the abort:
89
- // a hung turn must not hold one of the 4 slots (and its waiters)
90
- // forever — the same guard execution.ts gives task-type children.
91
- let rejectAborted = () => { };
92
- const abortedTurn = new Promise((_, reject) => { rejectAborted = reject; });
93
- abortedTurn.catch(() => { }); // handled via the race; never unhandled
94
+ // Cancel the SDK turn, but release our wait even if its abort hangs.
94
95
  const abort = () => {
95
- void session.abort();
96
- rejectAborted(new Error("cancelled"));
96
+ void Promise.resolve().then(() => session.abort())
97
+ .catch((error) => log.warn(`run ${run.id} session abort failed`, error));
97
98
  };
98
99
  signal.addEventListener("abort", abort, { once: true });
99
- // A pre-aborted signal never fires the listener: check before the
100
- // prompt starts or a cancelled run would hang until its timeout.
101
- if (signal.aborted)
102
- throw new Error("cancelled");
103
100
  try {
101
+ // Pre-abort does not fire a newly registered listener; keep the check
102
+ // inside finally's scope so it also restores retention/subscriptions.
103
+ signal.throwIfAborted();
104
104
  const turn = session.systemInput(prompt, {
105
105
  kind: "task-delegation",
106
106
  taskId: run.taskId,
@@ -110,7 +110,7 @@ export class AgentTaskRunner {
110
110
  }, "prompt");
111
111
  await Promise.resolve();
112
112
  this.messages.deliverPendingControls(run);
113
- await Promise.race([turn, abortedTurn]);
113
+ await this.untilAborted(turn, signal);
114
114
  if (signal.aborted)
115
115
  throw new Error("cancelled");
116
116
  // Before the fallback below: on a reused session that reads the
@@ -119,7 +119,7 @@ export class AgentTaskRunner {
119
119
  if (failure)
120
120
  throw new Error(failure);
121
121
  if (!text) {
122
- const history = await session.history();
122
+ const history = await this.untilAborted(session.history(), signal);
123
123
  text = [...history].reverse().find((turn) => turn.role === "assistant")?.text ?? "";
124
124
  }
125
125
  // The chat contract is injected into task sessions too, so a child's
@@ -138,15 +138,16 @@ export class AgentTaskRunner {
138
138
  signal.removeEventListener("abort", abort);
139
139
  unsubscribe();
140
140
  }
141
- });
142
- }
143
- finally {
144
- this.releaseSlot(run.id);
145
- }
141
+ }
142
+ finally {
143
+ this.releaseSlot(run.id);
144
+ }
145
+ });
146
146
  }
147
- async resolveSession(run, action) {
147
+ async resolveSession(run, action, signal) {
148
+ signal.throwIfAborted();
148
149
  if (run.targetSessionId) {
149
- return this.router.ensure({ channelId: "task", conversationId: run.targetSessionId });
150
+ return this.untilAborted(this.router.ensure({ channelId: "task", conversationId: run.targetSessionId }), signal);
150
151
  }
151
152
  const policy = action.session;
152
153
  // Definitions stored before fork was removed still say `"fork"`. Refused by
@@ -157,7 +158,8 @@ export class AgentTaskRunner {
157
158
  }
158
159
  const cwd = policy.mode === "fresh"
159
160
  ? policy.cwd
160
- : (await this.factory.find(policy.sessionId))?.cwd ?? "";
161
+ : (await this.untilAborted(this.factory.find(policy.sessionId), signal))?.cwd ?? "";
162
+ signal.throwIfAborted();
161
163
  if (!cwd)
162
164
  throw new Error("could not resolve child working directory");
163
165
  const opts = {
@@ -169,33 +171,52 @@ export class AgentTaskRunner {
169
171
  (run.sourceSessionId ? this.router.modelOf(run.sourceSessionId) : undefined),
170
172
  thinking: action.launch?.thinking,
171
173
  };
172
- const session = await this.factory.create(opts);
173
- run.targetSessionId = session.id;
174
- run.context.sessionId = session.id;
175
- run.context.cwd = cwd;
176
- this.store.saveRun(run);
177
- this.router.attach({ channelId: "task", conversationId: session.id }, session);
178
- this.changed(run);
174
+ const opening = this.factory.create(opts).then(async (session) => {
175
+ // SDK creation cannot be cancelled. A late session still belongs to
176
+ // this run; read back its terminal row so delivery updates are preserved.
177
+ // Remember it on the live run too: cancellation's final save can race
178
+ // this callback before it finishes writing the terminal record.
179
+ run.targetSessionId = session.id;
180
+ run.context.sessionId = session.id;
181
+ run.context.cwd = cwd;
182
+ const owner = signal.aborted ? this.store.getRun(run.id) ?? run : run;
183
+ owner.targetSessionId = run.targetSessionId;
184
+ owner.context = run.context;
185
+ try {
186
+ this.store.saveRun(owner);
187
+ this.changed(owner);
188
+ }
189
+ finally {
190
+ if (signal.aborted)
191
+ await session.dispose();
192
+ else
193
+ this.router.attach({ channelId: "task", conversationId: session.id }, session);
194
+ }
195
+ return session;
196
+ });
197
+ // A late rejection cannot reach execute's already-cancelled race.
198
+ void opening.catch((error) => {
199
+ if (signal.aborted)
200
+ log.warn(`session creation for cancelled run ${run.id} failed`, error);
201
+ });
202
+ const session = await this.untilAborted(opening, signal);
203
+ signal.throwIfAborted();
179
204
  return session;
180
205
  }
181
206
  async acquireSlot(run, signal) {
182
207
  while (this.active.size >= MAX_ACTIVE_AGENTS) {
183
- if (signal.aborted)
184
- throw new Error("cancelled");
185
- await new Promise((resolve, reject) => {
186
- const wake = () => {
187
- signal.removeEventListener("abort", abort);
188
- this.slotWaiters.delete(wake);
189
- resolve();
190
- };
191
- const abort = () => {
192
- this.slotWaiters.delete(wake);
193
- reject(new Error("cancelled"));
194
- };
195
- this.slotWaiters.add(wake);
196
- signal.addEventListener("abort", abort, { once: true });
197
- });
208
+ let wake = () => { };
209
+ try {
210
+ await this.untilAborted(new Promise((resolve) => {
211
+ wake = resolve;
212
+ this.slotWaiters.add(wake);
213
+ }), signal);
214
+ }
215
+ finally {
216
+ this.slotWaiters.delete(wake);
217
+ }
198
218
  }
219
+ signal.throwIfAborted();
199
220
  this.active.add(run.id);
200
221
  }
201
222
  releaseSlot(id) {
@@ -203,43 +224,58 @@ export class AgentTaskRunner {
203
224
  for (const wake of this.slotWaiters)
204
225
  wake();
205
226
  }
206
- async withSession(sessionId, fn) {
227
+ async untilAborted(promise, signal) {
228
+ let abort = () => { };
229
+ const cancelled = new Promise((_, reject) => {
230
+ abort = () => reject(new Error("cancelled"));
231
+ signal.addEventListener("abort", abort, { once: true });
232
+ if (signal.aborted)
233
+ abort();
234
+ });
235
+ try {
236
+ return await Promise.race([promise, cancelled]);
237
+ }
238
+ finally {
239
+ signal.removeEventListener("abort", abort);
240
+ }
241
+ }
242
+ async withSession(sessionId, signal, fn) {
243
+ signal.throwIfAborted();
207
244
  const previous = this.sessionTails.get(sessionId) ?? Promise.resolve();
208
245
  let release = () => { };
209
246
  const gate = new Promise((resolve) => { release = resolve; });
210
247
  const tail = previous.then(() => gate);
211
248
  this.sessionTails.set(sessionId, tail);
212
- await previous;
249
+ // Even if this waiter is cancelled, its tail still includes its predecessor.
250
+ // Only delete after that tail drains, or a new arrival could skip the queue.
251
+ void tail.then(() => {
252
+ if (this.sessionTails.get(sessionId) === tail)
253
+ this.sessionTails.delete(sessionId);
254
+ });
213
255
  try {
256
+ await this.untilAborted(previous, signal);
257
+ signal.throwIfAborted();
214
258
  return await fn();
215
259
  }
216
260
  finally {
217
261
  release();
218
- if (this.sessionTails.get(sessionId) === tail)
219
- this.sessionTails.delete(sessionId);
220
262
  }
221
263
  }
222
264
  async waitUntilIdle(session, signal) {
223
265
  // The session's own event stream is the only busy/idle signal — no polling.
224
266
  while (session.state === "streaming") {
225
- if (signal.aborted)
226
- throw new Error("cancelled");
227
- await new Promise((resolve, reject) => {
228
- const settle = (error) => {
229
- unsubscribe();
230
- signal.removeEventListener("abort", onAbort);
231
- if (error)
232
- reject(error);
233
- else
234
- resolve();
235
- };
236
- const onAbort = () => settle(new Error("cancelled"));
237
- const unsubscribe = session.subscribe((event) => {
238
- if (event.type === "state" && event.state === "idle")
239
- settle();
240
- });
241
- signal.addEventListener("abort", onAbort, { once: true });
242
- });
267
+ let unsubscribe = () => { };
268
+ try {
269
+ await this.untilAborted(new Promise((resolve) => {
270
+ unsubscribe = session.subscribe((event) => {
271
+ if (event.type === "state" && event.state === "idle")
272
+ resolve();
273
+ });
274
+ }), signal);
275
+ }
276
+ finally {
277
+ unsubscribe();
278
+ }
243
279
  }
244
280
  }
245
281
  }
@@ -1,8 +1,6 @@
1
1
  // What a finished run says to the session that delegated it. Delivery itself
2
2
  // belongs to outbox.ts; this file owns the run vocabulary and the batching.
3
- import { Router } from "../core/router.js";
4
3
  import { Outbox } from "./outbox.js";
5
- import { TaskStore } from "./store.js";
6
4
  /** How a run is named to the session that gets its result: the run id, and the
7
5
  * session that did the work — a relayer's next move is a deep link to it
8
6
  * (`#/session/<id>`), and without this line that costs a second tool call.
@@ -28,7 +26,7 @@ export function runResultText(run) {
28
26
  if (run.result?.type === "watch")
29
27
  result = "Watch condition did not match";
30
28
  if (result.length > 8000)
31
- result = `${result.slice(0, 8000)}\n[truncated — task tool get run_id ${run.id} returns the full text]`;
29
+ result = `${result.slice(0, 8000)}\n[truncated — task tool recover run_id ${run.id} with a reason returns the full text]`;
32
30
  return result;
33
31
  }
34
32
  export class TaskCallbacks {
@@ -5,6 +5,7 @@ import { spawn } from "node:child_process";
5
5
  import { logger } from "../log.js";
6
6
  const log = logger("tasks");
7
7
  const OUTPUT_LIMIT = 1024 * 1024;
8
+ const KILL_GRACE_MS = 250;
8
9
  class CappedOutput {
9
10
  chunks = [];
10
11
  bytes = 0;
@@ -38,34 +39,52 @@ export function runBash(script, cwd, input, signal) {
38
39
  const stdout = new CappedOutput();
39
40
  const stderr = new CappedOutput();
40
41
  let settled = false;
41
- const kill = () => {
42
+ let killTimer;
43
+ const kill = (signal) => {
42
44
  if (!child.pid)
43
45
  return;
44
46
  try {
45
47
  if (process.platform === "win32")
46
- child.kill("SIGTERM");
48
+ child.kill(signal);
47
49
  else
48
- process.kill(-child.pid, "SIGTERM");
50
+ process.kill(-child.pid, signal);
49
51
  }
50
- catch {
51
- // The process may have exited between the state check and kill.
52
+ catch (err) {
53
+ // ESRCH means the owned process group already exited.
54
+ if (err.code !== "ESRCH")
55
+ log.warn(`run ${signal} failed: ${String(err)}`);
52
56
  }
53
57
  };
54
- signal.addEventListener("abort", kill, { once: true });
58
+ const abort = () => {
59
+ kill("SIGTERM");
60
+ killTimer = setTimeout(() => kill("SIGKILL"), KILL_GRACE_MS);
61
+ killTimer.unref();
62
+ };
63
+ const cleanup = () => {
64
+ clearTimeout(killTimer);
65
+ signal.removeEventListener("abort", abort);
66
+ };
67
+ signal.addEventListener("abort", abort, { once: true });
68
+ if (signal.aborted)
69
+ abort();
55
70
  child.stdout.on("data", (chunk) => stdout.add(chunk));
56
71
  child.stderr.on("data", (chunk) => stderr.add(chunk));
57
72
  child.on("error", (err) => {
58
73
  if (settled)
59
74
  return;
60
75
  settled = true;
61
- signal.removeEventListener("abort", kill);
76
+ cleanup();
62
77
  reject(err);
63
78
  });
64
79
  child.on("close", (code) => {
65
80
  if (settled)
66
81
  return;
67
82
  settled = true;
68
- signal.removeEventListener("abort", kill);
83
+ // A TERM trap may exit the shell while a descendant ignores TERM and
84
+ // has redirected its pipes. Closing the shell must not spare that group.
85
+ if (signal.aborted)
86
+ kill("SIGKILL");
87
+ cleanup();
69
88
  resolve({
70
89
  exitCode: code,
71
90
  stdout: stdout.text(),
@@ -3,11 +3,7 @@
3
3
  // while it goes, settled exactly once. Command and agent work lives in
4
4
  // command.ts and agent.ts; this file owns the lifecycle they share.
5
5
  import { logger } from "../log.js";
6
- import { AgentTaskRunner } from "./agent.js";
7
- import { TaskCallbacks } from "./callbacks.js";
8
6
  import { runBash } from "./command.js";
9
- import { TaskDefinitions } from "./definitions.js";
10
- import { TaskStore } from "./store.js";
11
7
  const log = logger("tasks");
12
8
  export class TaskExecution {
13
9
  store;
@@ -40,6 +36,8 @@ export class TaskExecution {
40
36
  let timedOut = false;
41
37
  let cause;
42
38
  const timeout = setTimeout(() => {
39
+ if (controller.signal.aborted)
40
+ return;
43
41
  timedOut = true;
44
42
  controller.abort();
45
43
  }, run.context.definition.timeoutSeconds * 1000);
@@ -58,6 +56,7 @@ export class TaskExecution {
58
56
  }
59
57
  if (run.matched !== false)
60
58
  run.result = await this.executeAction(run, controller.signal);
59
+ controller.signal.throwIfAborted();
61
60
  run.state = "succeeded";
62
61
  if (definition.trigger.type === "watch" && !run.resumedFromRunId && definition.trigger.mode === "once" && run.matched) {
63
62
  // As the definition's own creator: a one-shot watch retiring itself is
@@ -112,6 +111,7 @@ export class TaskExecution {
112
111
  }
113
112
  }
114
113
  async executeAction(run, signal) {
114
+ signal.throwIfAborted();
115
115
  const action = run.context.definition.action;
116
116
  if (action.type === "bash") {
117
117
  this.markRunning(run);
@@ -143,6 +143,8 @@ export class TaskExecution {
143
143
  rejectWait(new Error("cancelled"));
144
144
  };
145
145
  signal.addEventListener("abort", onAbort, { once: true });
146
+ if (signal.aborted)
147
+ onAbort();
146
148
  try {
147
149
  const done = await Promise.race([this.host.waitForRun(child.id), aborted]);
148
150
  if (done.state !== "succeeded")