@timqi/pier 0.1.14 → 0.2.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 (126) hide show
  1. package/README.md +44 -22
  2. package/dist/agent/config.js +2 -2
  3. package/dist/agent/events.js +32 -3
  4. package/dist/agent/packages.js +6 -10
  5. package/dist/agent/pi.js +111 -68
  6. package/dist/agent/roles.js +103 -0
  7. package/dist/agent/types.js +78 -0
  8. package/dist/boards/boards.js +42 -32
  9. package/dist/channels/chains.js +0 -4
  10. package/dist/channels/commands.js +7 -12
  11. package/dist/channels/config.js +27 -1
  12. package/dist/channels/control.js +5 -16
  13. package/dist/channels/conversations.js +0 -5
  14. package/dist/channels/lark-api.js +20 -26
  15. package/dist/channels/lark-outbound.js +16 -29
  16. package/dist/channels/lark-panel.js +1 -1
  17. package/dist/channels/lark.js +61 -39
  18. package/dist/channels/lines.js +33 -1
  19. package/dist/channels/panel.js +17 -107
  20. package/dist/channels/routes.js +11 -21
  21. package/dist/channels/runtime.js +34 -25
  22. package/dist/channels/slack-outbound.js +6 -15
  23. package/dist/channels/slack-panel.js +1 -1
  24. package/dist/channels/slack-render.js +1 -1
  25. package/dist/channels/slack.js +63 -57
  26. package/dist/channels/types.js +1 -1
  27. package/dist/config-sync.js +42 -37
  28. package/dist/core/chain.js +202 -0
  29. package/dist/core/identity.js +34 -11
  30. package/dist/core/inbound-file.js +4 -3
  31. package/dist/core/reply.js +96 -80
  32. package/dist/core/router.js +111 -118
  33. package/dist/core/types.js +36 -77
  34. package/dist/db.js +33 -2
  35. package/dist/main.js +46 -37
  36. package/dist/settings.js +25 -18
  37. package/dist/tasks/agent.js +11 -5
  38. package/dist/tasks/callbacks.js +55 -5
  39. package/dist/tasks/cli.js +22 -11
  40. package/dist/tasks/definitions.js +30 -17
  41. package/dist/tasks/execution.js +2 -2
  42. package/dist/tasks/groups.js +5 -2
  43. package/dist/tasks/messages.js +7 -7
  44. package/dist/tasks/open-items.js +70 -0
  45. package/dist/tasks/operations.js +76 -39
  46. package/dist/tasks/outbox.js +19 -6
  47. package/dist/tasks/routes.js +19 -249
  48. package/dist/tasks/service.js +145 -34
  49. package/dist/tasks/store.js +119 -56
  50. package/dist/tasks/types.js +5 -0
  51. package/dist/tools.js +2 -2
  52. package/dist/web/auth.js +2 -2
  53. package/dist/web/config-sync.js +4 -5
  54. package/dist/web/config.js +9 -2
  55. package/dist/web/explorer.js +2 -2
  56. package/dist/web/fs.js +16 -1
  57. package/dist/web/instance.js +8 -5
  58. package/dist/web/packages.js +1 -1
  59. package/dist/web/passkeys.js +3 -3
  60. package/dist/web/providers.js +3 -3
  61. package/dist/web/public/assets/code-BZwvPani.js +2 -0
  62. package/dist/web/public/assets/code-BZwvPani.js.br +0 -0
  63. package/dist/web/public/assets/code-BZwvPani.js.gz +0 -0
  64. package/dist/web/public/assets/explorer-DDoVzMLQ.js +5 -0
  65. package/dist/web/public/assets/explorer-DDoVzMLQ.js.br +0 -0
  66. package/dist/web/public/assets/explorer-DDoVzMLQ.js.gz +0 -0
  67. package/dist/web/public/assets/highlight-5Bl8zZ8q.js +72 -0
  68. package/dist/web/public/assets/highlight-5Bl8zZ8q.js.br +0 -0
  69. package/dist/web/public/assets/highlight-5Bl8zZ8q.js.gz +0 -0
  70. package/dist/web/public/assets/{hljs-tWqyD59G.js → hljs-BRz6a8Dq.js} +2 -2
  71. package/dist/web/public/assets/hljs-BRz6a8Dq.js.br +0 -0
  72. package/dist/web/public/assets/hljs-BRz6a8Dq.js.gz +0 -0
  73. package/dist/web/public/assets/index-BpFzCwlo.js +7 -0
  74. package/dist/web/public/assets/index-BpFzCwlo.js.br +0 -0
  75. package/dist/web/public/assets/index-BpFzCwlo.js.gz +0 -0
  76. package/dist/web/public/assets/index-wAGxFFPj.css +2 -0
  77. package/dist/web/public/assets/index-wAGxFFPj.css.br +0 -0
  78. package/dist/web/public/assets/index-wAGxFFPj.css.gz +0 -0
  79. package/dist/web/public/assets/report-zjE8Bt8S.js +8 -0
  80. package/dist/web/public/assets/report-zjE8Bt8S.js.br +0 -0
  81. package/dist/web/public/assets/report-zjE8Bt8S.js.gz +0 -0
  82. package/dist/web/public/assets/settings-Cv7rbeqf.js +9 -0
  83. package/dist/web/public/assets/settings-Cv7rbeqf.js.br +0 -0
  84. package/dist/web/public/assets/settings-Cv7rbeqf.js.gz +0 -0
  85. package/dist/web/public/index.html +40 -109
  86. package/dist/web/public/index.html.br +0 -0
  87. package/dist/web/public/index.html.gz +0 -0
  88. package/dist/web/public/manifest.webmanifest +0 -2
  89. package/dist/web/push.js +1 -1
  90. package/dist/web/server.js +91 -116
  91. package/dist/web/session-state.js +5 -40
  92. package/docs/deploy.md +5 -3
  93. package/package.json +2 -1
  94. package/skills/pier-boards/SKILL.md +14 -4
  95. package/skills/pier-help/SKILL.md +57 -23
  96. package/skills/pier-tasks/SKILL.md +58 -18
  97. package/dist/channels/handoff.js +0 -94
  98. package/dist/web/public/assets/activity-Ds6fCHnb.js +0 -5
  99. package/dist/web/public/assets/activity-Ds6fCHnb.js.br +0 -0
  100. package/dist/web/public/assets/activity-Ds6fCHnb.js.gz +0 -0
  101. package/dist/web/public/assets/boards-BKCj6EwK.js +0 -1
  102. package/dist/web/public/assets/boards-BKCj6EwK.js.br +0 -0
  103. package/dist/web/public/assets/boards-BKCj6EwK.js.gz +0 -0
  104. package/dist/web/public/assets/explorer-DnTm975c.js +0 -4
  105. package/dist/web/public/assets/explorer-DnTm975c.js.br +0 -0
  106. package/dist/web/public/assets/explorer-DnTm975c.js.gz +0 -0
  107. package/dist/web/public/assets/hljs-tWqyD59G.js.br +0 -0
  108. package/dist/web/public/assets/hljs-tWqyD59G.js.gz +0 -0
  109. package/dist/web/public/assets/index-BQo-haPN.js +0 -85
  110. package/dist/web/public/assets/index-BQo-haPN.js.br +0 -0
  111. package/dist/web/public/assets/index-BQo-haPN.js.gz +0 -0
  112. package/dist/web/public/assets/index-DiHj0w1i.css +0 -2
  113. package/dist/web/public/assets/index-DiHj0w1i.css.br +0 -0
  114. package/dist/web/public/assets/index-DiHj0w1i.css.gz +0 -0
  115. package/dist/web/public/assets/runs-C_AthWcW.js +0 -1
  116. package/dist/web/public/assets/runs-C_AthWcW.js.br +0 -0
  117. package/dist/web/public/assets/runs-C_AthWcW.js.gz +0 -0
  118. package/dist/web/public/assets/settings-DDaAtFlc.js +0 -5
  119. package/dist/web/public/assets/settings-DDaAtFlc.js.br +0 -0
  120. package/dist/web/public/assets/settings-DDaAtFlc.js.gz +0 -0
  121. package/dist/web/public/assets/task-runs-0pvdITiV.js +0 -3
  122. package/dist/web/public/assets/task-runs-0pvdITiV.js.br +0 -0
  123. package/dist/web/public/assets/task-runs-0pvdITiV.js.gz +0 -0
  124. package/dist/web/public/assets/tasks-Bz29caHJ.js +0 -4
  125. package/dist/web/public/assets/tasks-Bz29caHJ.js.br +0 -0
  126. package/dist/web/public/assets/tasks-Bz29caHJ.js.gz +0 -0
@@ -3,11 +3,12 @@
3
3
  // drain needs. Decisions belong to the files beside it.
4
4
  import { logger } from "../log.js";
5
5
  import { AgentTaskRunner } from "./agent.js";
6
- import { TaskCallbacks } from "./callbacks.js";
6
+ import { DESIGN_FINAL, MILESTONE, runCwd, settleCallback, TaskCallbacks } from "./callbacks.js";
7
7
  import { TaskDefinitions, requiredString } from "./definitions.js";
8
8
  import { TaskExecution } from "./execution.js";
9
9
  import { TaskGroups } from "./groups.js";
10
10
  import { TaskMessenger } from "./messages.js";
11
+ import { openItems, recordOpenItems } from "./open-items.js";
11
12
  import { TaskRunQueue } from "./runs.js";
12
13
  import { handleTask } from "./operations.js";
13
14
  import { isTerminal } from "./types.js";
@@ -36,17 +37,23 @@ export class TaskService {
36
37
  runs;
37
38
  execution;
38
39
  constructor(store, factory, router, hub,
39
- /** Structural: tasks/ must not import settings.ts. Absent in bare test rigs. */
40
+ /** Structural: tasks/ must not import settings.ts. */
40
41
  instance) {
41
42
  this.store = store;
42
43
  this.factory = factory;
43
44
  this.router = router;
44
45
  this.hub = hub;
45
46
  this.instance = instance;
47
+ const headOf = (id) => instance.continuous.chainOf(id)?.[0] ?? id;
48
+ const conversation = () => instance.continuous.members()[0]?.sessionId ?? null;
46
49
  const unreachable = (sessionId, what, why) => this.unreachable(sessionId, what, why);
47
- this.messages = new TaskMessenger(store, router, hub, unreachable);
48
- this.definitions = new TaskDefinitions(store, factory, router, hub, instance?.systemActions);
49
- this.callbacks = new TaskCallbacks(store, router, (run) => this.changed(run), unreachable);
50
+ this.messages = new TaskMessenger(store, router, hub, unreachable, (runId) => {
51
+ const run = store.getRun(runId);
52
+ if (run)
53
+ this.status(run);
54
+ });
55
+ this.definitions = new TaskDefinitions(store, factory, router, hub, instance.systemActions);
56
+ this.callbacks = new TaskCallbacks(store, router, (run) => this.changed(run), unreachable, headOf, this.milestone, conversation);
50
57
  this.groups = new TaskGroups(store, router, {
51
58
  getRun: (id) => this.getRun(id),
52
59
  cancel: (id) => { this.cancel(id); },
@@ -58,7 +65,7 @@ export class TaskService {
58
65
  groupId,
59
66
  }),
60
67
  startMember: (run) => this.runs.start(run),
61
- }, (group) => this.hub.emitWorkspace({ type: "task-group-changed", groupId: group.id }), unreachable);
68
+ }, (group) => this.hub.emitWorkspace({ type: "task-group-changed", groupId: group.id }), unreachable, headOf, this.milestone);
62
69
  const agent = new AgentTaskRunner(factory, router, store, this.messages, (run) => this.changed(run));
63
70
  this.execution = new TaskExecution(store, this.definitions, this.callbacks, agent, {
64
71
  runChild: (taskId, parent) => this.run(taskId, parent.input, "task", parent.id, {
@@ -73,6 +80,64 @@ export class TaskService {
73
80
  changed: (run) => this.changed(run),
74
81
  });
75
82
  this.runs = new TaskRunQueue(store, this.callbacks, (run) => this.execution.start(run), (run) => this.changed(run));
83
+ router.onTurnEnd((sessionId, text) => {
84
+ // Only the head's turns write the list.
85
+ if (instance.continuous.members()[0]?.sessionId === sessionId) {
86
+ try {
87
+ if (recordOpenItems(store, text, Date.now()))
88
+ hub.emitWorkspace({ type: "open-items-changed" });
89
+ }
90
+ catch (err) {
91
+ log.warn(`open items: the markers in ${sessionId}'s reply could not be written`, err);
92
+ }
93
+ }
94
+ if (!DESIGN_FINAL.test(text))
95
+ return;
96
+ // On Pi's dispatch stack, which must not unwind.
97
+ try {
98
+ this.designFinal(sessionId, text);
99
+ }
100
+ catch (err) {
101
+ log.error(`lead ${sessionId}: a Design final: outside any run could not be recorded; main was not told`, err);
102
+ }
103
+ });
104
+ }
105
+ /** A design lead finalized in its own session, a turn outside any run (the
106
+ * user confirmed there): recorded as a finished run of the lead whose result
107
+ * is that reply, so it reports to the lead's supervisor, and the session list and
108
+ * `/status` see it, exactly as a run's `Design final:` does. */
109
+ designFinal(sessionId, text) {
110
+ // A run's own turn reports through the run.
111
+ if (this.store.leadPhaseOf(sessionId) !== "design" || this.store.findActiveRunForTarget(sessionId)?.state === "running")
112
+ return;
113
+ const last = this.store.latestRunForTarget(sessionId);
114
+ if (!last)
115
+ return;
116
+ const run = this.store.transact(() => {
117
+ const run = this.runs.prepare(last.context.definition, null, "agent", null, {
118
+ invokedBySessionId: last.invokedBySessionId,
119
+ sourceSessionId: last.invokedBySessionId,
120
+ targetSessionId: sessionId,
121
+ sessionMode: "reuse",
122
+ resumedFromRunId: last.id,
123
+ callbackSessionId: last.callbackSessionId,
124
+ callbackMode: last.callbackMode,
125
+ background: last.background,
126
+ });
127
+ const now = Date.now();
128
+ run.state = "succeeded";
129
+ run.startedAt = run.finishedAt = now;
130
+ run.result = { type: "agent", text, sessionId };
131
+ run.context.sessionId = sessionId;
132
+ settleCallback(run, this.store);
133
+ this.store.saveRun(run);
134
+ return run;
135
+ });
136
+ if (run.callbackSessionId === null)
137
+ log.warn(`lead ${sessionId}: Design final: recorded as run ${run.id}; the lead reports to no one`);
138
+ else
139
+ log.info(`lead ${sessionId}: Design final: outside any run, recorded as run ${run.id} for ${run.callbackSessionId}`);
140
+ this.runs.start(run);
76
141
  }
77
142
  start(tickMs = 1000) {
78
143
  if (this.timer)
@@ -145,7 +210,7 @@ export class TaskService {
145
210
  create(raw, creator = "http") {
146
211
  return this.definitions.create(raw, creator);
147
212
  }
148
- /** `by` is how owning code says so; the routes and `pier task` have none (definitions.ts). */
213
+ /** `by` is how owning code says so; `pier task` has none (definitions.ts). */
149
214
  update(id, raw, by) {
150
215
  return this.definitions.update(id, raw, by);
151
216
  }
@@ -155,9 +220,9 @@ export class TaskService {
155
220
  archive(id, by) {
156
221
  return this.definitions.archive(id, by);
157
222
  }
158
- listRuns(taskId, limit = 50, offset = 0) {
159
- this.get(taskId);
160
- return this.store.listRuns(taskId, Math.min(Math.max(limit, 1), 200), Math.max(offset, 0));
223
+ /** `pier task list`'s last run: the newest `limit` of a definition's runs. */
224
+ listRuns(taskId, limit = 1) {
225
+ return this.store.listRuns(taskId, limit);
161
226
  }
162
227
  getRun(id) {
163
228
  const run = this.store.getRun(id);
@@ -165,24 +230,6 @@ export class TaskService {
165
230
  throw new Error(`unknown task run: ${id}`);
166
231
  return run;
167
232
  }
168
- getRunView(id) {
169
- const run = this.getRun(id);
170
- return { ...run,
171
- groupCallbackState: run.groupId ? this.store.getGroup(run.groupId)?.callbackState ?? null : null };
172
- }
173
- listMessages(runId) {
174
- this.getRun(runId);
175
- return this.messages.list(runId);
176
- }
177
- queryRuns(query = {}) {
178
- return this.store.queryRuns(query);
179
- }
180
- activityRuns(since) {
181
- return this.store.activityRuns(since);
182
- }
183
- recentMessages(since) {
184
- return this.messages.recent(since);
185
- }
186
233
  /** Every run, not the last hour's: the card is a message in the transcript. */
187
234
  backgroundRuns(sessionId) {
188
235
  return this.store.listRunsForSession(sessionId, 200)
@@ -190,6 +237,28 @@ export class TaskService {
190
237
  .reverse()
191
238
  .map((run) => this.backgroundRun(run));
192
239
  }
240
+ /** What the session's queue shows beside Pi's own: follow-ups waiting for it to idle. */
241
+ parkedMessages(sessionId) {
242
+ return this.store.pendingFollowUpsTo(sessionId).map((message) => ({
243
+ messageId: message.id,
244
+ runName: this.store.getRun(message.runId)?.context.definition.name ?? message.runId,
245
+ text: message.content,
246
+ }));
247
+ }
248
+ /** The run ledger: runs any of `sessionIds` launched, in flight or finished since `since`, at most 200. */
249
+ ledger(sessionIds, since) {
250
+ return this.store.ledgerRuns(sessionIds, since).map(ledgerRun);
251
+ }
252
+ /** Design leads no run of which has reported `Design final:`, by their creating run: the user's to finalize. */
253
+ openDesigns() {
254
+ return [...this.store.leads().values()].flatMap((lead) => {
255
+ const run = lead.designOpen ? this.store.getRun(lead.runId) : undefined;
256
+ return run ? [ledgerRun(run)] : [];
257
+ });
258
+ }
259
+ openItems(now = Date.now()) {
260
+ return openItems(this, this.router, this.instance.continuous.members().map((m) => m.sessionId), this.openDesigns(), now);
261
+ }
193
262
  activeBackgroundRunCounts() {
194
263
  return this.store.countActiveBackgroundRunsBySession();
195
264
  }
@@ -262,15 +331,19 @@ export class TaskService {
262
331
  return this.messages.control(run, fromSessionId, mode, message);
263
332
  }
264
333
  resume(id, message, provenance = {}) {
334
+ const run = this.prepareResume(this.getRun(id), message, provenance);
335
+ this.runs.start(run);
336
+ return run;
337
+ }
338
+ prepareResume(prior, message, provenance) {
265
339
  this.refusePaused();
266
- const prior = this.getRun(id);
267
340
  if (!isTerminal(prior.state))
268
341
  throw new Error("run must be terminal before resume");
269
342
  if (prior.context.definition.action.type !== "agent" || !prior.targetSessionId) {
270
343
  throw new Error("only persisted Agent runs can be resumed");
271
344
  }
272
345
  const prompt = requiredString(message, "message");
273
- const run = this.runs.prepare(prior.context.definition, null, "agent", null, {
346
+ return this.runs.prepare(prior.context.definition, null, "agent", null, {
274
347
  ...provenance,
275
348
  sourceSessionId: provenance.invokedBySessionId ?? prior.invokedBySessionId,
276
349
  targetSessionId: prior.targetSessionId,
@@ -278,16 +351,46 @@ export class TaskService {
278
351
  resumedFromRunId: prior.id,
279
352
  resumePrompt: prompt,
280
353
  });
281
- this.runs.start(run);
282
- return run;
283
354
  }
355
+ /** A lead reports once per wave (docs/design/10-continuous-session.md
356
+ * §Feature lead): the result that leaves nothing owed to it in flight
357
+ * resumes its last run, whose own callback reaches its supervisor. */
358
+ milestone = (sessionId, text, settle) => {
359
+ if (this.store.roleOf(sessionId) !== "lead" || this.store.countOwedTo(sessionId) > 0)
360
+ return "plain";
361
+ const last = this.store.latestRunForTarget(sessionId);
362
+ if (!last || last.callbackSessionId === null)
363
+ return "plain";
364
+ // A running turn reports upstream when it ends; a drain leaves the result to the next boot.
365
+ if (!isTerminal(last.state) || this.paused)
366
+ return "wait";
367
+ try {
368
+ // One transaction: a crash between the resume and the marks would resume twice.
369
+ const run = this.store.transact(() => {
370
+ const run = this.prepareResume(last, `${MILESTONE}\n\n${text()}`, {
371
+ invokedBySessionId: last.invokedBySessionId,
372
+ callbackSessionId: last.callbackSessionId,
373
+ callbackMode: last.callbackMode,
374
+ background: last.background,
375
+ });
376
+ settle();
377
+ return run;
378
+ });
379
+ this.runs.start(run);
380
+ return "resumed";
381
+ }
382
+ catch (err) {
383
+ log.error(`lead ${sessionId}: the wave's last result could not resume run ${last.id}; delivering it as a plain callback`, err);
384
+ return "plain";
385
+ }
386
+ };
284
387
  /** What `pier task` asks, under the calling session's identity. */
285
388
  handle(raw, callerSessionId) {
286
- return handleTask(this, this.definitions, this.store, raw, callerSessionId);
389
+ return handleTask(this, this.definitions, this.store, raw, callerSessionId, this.instance.continuous);
287
390
  }
288
391
  /** An agent picks from names that exist right now, never from memory. */
289
392
  async models() {
290
- const menu = this.instance?.modelMenu() ?? [];
393
+ const menu = this.instance.modelMenu();
291
394
  if (menu.length)
292
395
  return { source: "menu", models: menu };
293
396
  return { source: "catalog", models: await this.factory.availableModels() };
@@ -355,12 +458,20 @@ export class TaskService {
355
458
  queuedAt: run.queuedAt,
356
459
  startedAt: run.startedAt,
357
460
  finishedAt: run.finishedAt,
461
+ // A finished run's pending messages only wait for the sweep to expire them.
462
+ queuedMessages: isTerminal(run.state) ? 0 : this.store.countPendingFollowUps(run.id),
358
463
  };
359
464
  }
360
465
  changed(run) {
361
466
  this.hub.emitWorkspace({ type: "task-run-changed", taskId: run.taskId, runId: run.id });
467
+ this.status(run);
468
+ }
469
+ status(run) {
362
470
  if (run.background && run.invokedBySessionId) {
363
471
  this.hub.emit(run.invokedBySessionId, { type: "task-status", run: this.backgroundRun(run) });
364
472
  }
365
473
  }
366
474
  }
475
+ function ledgerRun(run) {
476
+ return { runId: run.id, name: run.context.definition.name, state: run.state, targetSessionId: run.targetSessionId, cwd: runCwd(run), queuedAt: run.queuedAt, finishedAt: run.finishedAt };
477
+ }
@@ -1,6 +1,7 @@
1
1
  // Every query this area makes against pier.db, and nothing else; the schema is
2
2
  // db.ts's migration list.
3
3
  import { pierDb, statements, transact } from "../db.js";
4
+ import { createdRole } from "./types.js";
4
5
  const clamp = (limit, cap) => Math.min(Math.max(limit, 1), cap);
5
6
  /** Kept unmatched probes per watch: one page of history, which is all any
6
7
  * surface lists of a probe that found nothing. */
@@ -42,11 +43,8 @@ export class TaskStore {
42
43
  listDueTasks(now) {
43
44
  return this.#many("SELECT json FROM tasks WHERE next_run_at IS NOT NULL AND next_run_at <= ?", now);
44
45
  }
45
- listRuns(taskId, limit = 50, offset = 0) {
46
- return this.#many(`
47
- SELECT json FROM task_runs WHERE task_id = ?
48
- ORDER BY queued_at DESC LIMIT ? OFFSET ?
49
- `, taskId, limit, offset);
46
+ listRuns(taskId, limit = 50) {
47
+ return this.#many("SELECT json FROM task_runs WHERE task_id = ? ORDER BY queued_at DESC LIMIT ?", taskId, limit);
50
48
  }
51
49
  getRun(id) {
52
50
  return this.#one("SELECT json FROM task_runs WHERE id = ?", id);
@@ -79,57 +77,15 @@ export class TaskStore {
79
77
  )
80
78
  `).run(taskId, taskId, KEPT_PROBES);
81
79
  }
82
- /** The id breaks timestamp ties so a page boundary never repeats or skips a
83
- * row. Built per call, not cached: the filter set makes it. */
84
- queryRuns(query = {}) {
85
- const where = [];
86
- const params = [];
87
- const add = (sql, value) => {
88
- if (value !== undefined) {
89
- where.push(sql);
90
- params.push(value);
91
- }
92
- };
93
- add("r.state = ?", query.state);
94
- add("r.task_id = ?", query.taskId);
95
- add("json_extract(r.json, '$.triggerSource') = ?", query.source);
96
- add("r.queued_at >= ?", query.since);
97
- add("r.queued_at <= ?", query.until);
98
- // Keep this predicate identical to migration 17's partial index.
99
- if (!query.showUnmatched)
100
- where.push("NOT (r.state = 'succeeded' AND json_extract(r.json, '$.matched') IS 0)");
101
- if (query.cursor) {
102
- where.push("(r.queued_at, r.id) < (?, ?)");
103
- params.push(query.cursor.queuedAt, query.cursor.id);
104
- }
105
- const limit = clamp(query.limit ?? 50, 200);
106
- const rows = this.db.prepare(`
107
- SELECT r.json, g.callback_state AS group_callback_state
108
- FROM task_runs r LEFT JOIN task_groups g ON g.id = json_extract(r.json, '$.groupId')
109
- ${where.length ? `WHERE ${where.join(" AND ")}` : ""}
110
- ORDER BY r.queued_at DESC, r.id DESC LIMIT ?
111
- `).all(...params, limit + 1);
112
- const runs = rows.slice(0, limit).map((row) => ({
113
- ...JSON.parse(row.json), groupCallbackState: row.group_callback_state,
114
- }));
115
- const last = runs.at(-1);
116
- return {
117
- runs,
118
- nextCursor: rows.length > limit && last ? { queuedAt: last.queuedAt, id: last.id } : null,
119
- };
120
- }
121
- /** Activity never limits live work; only its optional history is bounded. */
122
- activityRuns(since) {
123
- const active = this.#many("SELECT json FROM task_runs WHERE state IN ('queued', 'running') ORDER BY queued_at DESC, id DESC");
124
- if (since === undefined)
125
- return active;
126
- const recent = this.#many(`
80
+ /** Runs launched by any of `sessionIds`: in flight, or finished at or after `since`. */
81
+ ledgerRuns(sessionIds, since) {
82
+ return this.#many(`
127
83
  SELECT json FROM task_runs
128
- WHERE state NOT IN ('queued', 'running') AND queued_at >= ?
84
+ WHERE json_extract(json, '$.invokedBySessionId') IN (SELECT value FROM json_each(?))
85
+ AND (state IN ('queued', 'running') OR json_extract(json, '$.finishedAt') >= ?)
129
86
  AND NOT (state = 'succeeded' AND json_extract(json, '$.matched') IS 0)
130
87
  ORDER BY queued_at DESC, id DESC LIMIT 200
131
- `, since);
132
- return [...active, ...recent].sort((a, b) => b.queuedAt - a.queuedAt || b.id.localeCompare(a.id));
88
+ `, JSON.stringify(sessionIds), since);
133
89
  }
134
90
  /** A `task` action's child runs; a cancel walks them. */
135
91
  listChildRuns(parentRunId) {
@@ -177,15 +133,96 @@ export class TaskStore {
177
133
  return new Map(rows.map((row) => [row.session_id, row.n]));
178
134
  }
179
135
  /** `fresh` is the one mode that makes a session rather than borrowing one;
180
- * these are the agent's conversations with itself, which the rail does not list. */
136
+ * these are the agent's conversations with itself, which the session list does not
137
+ * list. A feature lead's is the user's too, so it is not one of them. */
181
138
  taskOwnedSessionIds() {
182
139
  const rows = this.sql(`
183
140
  SELECT DISTINCT json_extract(json, '$.context.sessionId') AS id
184
141
  FROM task_runs
185
142
  WHERE json_extract(json, '$.sessionMode') = 'fresh' AND id IS NOT NULL
143
+ AND json_extract(json, '$.context.definition.action.launch.role') IS NOT 'lead'
186
144
  `).all();
187
145
  return new Set(rows.map((row) => row.id));
188
146
  }
147
+ /** The fresh run that made the session; a resume or a `--session`
148
+ * continuation never does, so the session's role is this run's for its life. */
149
+ creatorOf(sessionId) {
150
+ return this.#one(`
151
+ SELECT json FROM task_runs
152
+ WHERE json_extract(json, '$.targetSessionId') = ? AND json_extract(json, '$.sessionMode') = 'fresh'
153
+ ORDER BY queued_at LIMIT 1
154
+ `, sessionId);
155
+ }
156
+ roleOf(sessionId) {
157
+ const run = this.creatorOf(sessionId);
158
+ return run && createdRole(run);
159
+ }
160
+ leadPhaseOf(sessionId) {
161
+ const run = this.creatorOf(sessionId);
162
+ if (!run || createdRole(run) !== "lead" || run.context.definition.action.type !== "agent")
163
+ return undefined;
164
+ return run.context.definition.action.launch?.design ? "design" : "build";
165
+ }
166
+ /** Every lead session with its phase (as `leadPhaseOf`), its creating run,
167
+ * whether a run targeting it is queued or running, and — a design lead's —
168
+ * whether no run of it has reported `Design final:` yet, which leaves the
169
+ * design on the user; in one statement for the session list's listing. */
170
+ leads() {
171
+ const rows = this.sql(`
172
+ SELECT c.id, c.run_id, c.design, l.id IS NOT NULL AS live, f.id IS NOT NULL AS final FROM (
173
+ SELECT id AS run_id, json_extract(json, '$.targetSessionId') AS id,
174
+ json_extract(json, '$.context.definition.action.launch.role') AS role,
175
+ json_extract(json, '$.context.definition.action.launch.design') AS design,
176
+ ROW_NUMBER() OVER (PARTITION BY json_extract(json, '$.targetSessionId') ORDER BY queued_at) AS n
177
+ FROM task_runs
178
+ WHERE json_extract(json, '$.sessionMode') = 'fresh' AND json_extract(json, '$.targetSessionId') IS NOT NULL
179
+ ) c
180
+ LEFT JOIN (
181
+ SELECT DISTINCT json_extract(json, '$.targetSessionId') AS id FROM task_runs WHERE state IN ('queued', 'running')
182
+ ) l ON l.id = c.id
183
+ LEFT JOIN (
184
+ SELECT DISTINCT json_extract(json, '$.targetSessionId') AS id FROM task_runs
185
+ WHERE json_extract(json, '$.result.type') = 'agent'
186
+ AND instr(char(10) || json_extract(json, '$.result.text'), char(10) || 'Design final:') > 0
187
+ ) f ON f.id = c.id
188
+ WHERE c.n = 1 AND c.role = 'lead'
189
+ `).all();
190
+ return new Map(rows.map((r) => {
191
+ const phase = r.design === 1 ? "design" : "build";
192
+ return [r.id, { phase, runId: r.run_id, runLive: r.live === 1, designOpen: phase === "design" && r.final === 0 }];
193
+ }));
194
+ }
195
+ /** What a milestone resumes, and whose supervisor it reports to. */
196
+ latestRunForTarget(sessionId) {
197
+ return this.#one(`
198
+ SELECT json FROM task_runs WHERE json_extract(json, '$.targetSessionId') = ? ORDER BY queued_at DESC, id DESC LIMIT 1
199
+ `, sessionId);
200
+ }
201
+ /** Runs in flight whose result is owed to the session, their own or their
202
+ * unfinished group's; a finished group's losers still cancelling owe nothing. */
203
+ countOwedTo(sessionId) {
204
+ const row = this.sql(`
205
+ SELECT COUNT(*) AS n FROM task_runs r
206
+ WHERE r.state IN ('queued', 'running') AND (
207
+ json_extract(r.json, '$.callbackSessionId') = ?
208
+ OR EXISTS (SELECT 1 FROM task_groups g WHERE g.id = json_extract(r.json, '$.groupId')
209
+ AND g.finished_at IS NULL AND json_extract(g.json, '$.callbackSessionId') = ?))
210
+ `).get(sessionId, sessionId);
211
+ return row.n;
212
+ }
213
+ /** Whether a result is still coming to the session: a run in flight owed
214
+ * to it, or a finished run's or group's callback not yet delivered. */
215
+ awaitsResults(sessionId) {
216
+ if (this.countOwedTo(sessionId) > 0)
217
+ return true;
218
+ const row = this.sql(`
219
+ SELECT EXISTS (SELECT 1 FROM task_runs WHERE callback_state IN ('pending', 'failed')
220
+ AND json_extract(json, '$.callbackSessionId') = ?)
221
+ OR EXISTS (SELECT 1 FROM task_groups WHERE callback_state IN ('pending', 'failed')
222
+ AND json_extract(json, '$.callbackSessionId') = ?) AS owed
223
+ `).get(sessionId, sessionId);
224
+ return row.owed === 1;
225
+ }
189
226
  listRunsForSession(sessionId, limit = 50) {
190
227
  return this.#many(`
191
228
  SELECT json FROM task_runs
@@ -228,8 +265,21 @@ export class TaskStore {
228
265
  listMessages(runId) {
229
266
  return this.#many("SELECT json FROM task_messages WHERE run_id = ? ORDER BY created_at", runId);
230
267
  }
231
- listRecentMessages(since) {
232
- return this.#many("SELECT json FROM task_messages WHERE created_at >= ? ORDER BY created_at DESC LIMIT 200", since);
268
+ /** Follow-ups parked for the session until it idles: its queue shows them. */
269
+ pendingFollowUpsTo(sessionId) {
270
+ return this.#many(`
271
+ SELECT json FROM task_messages
272
+ WHERE state = 'pending' AND json_extract(json, '$.kind') = 'follow_up'
273
+ AND json_extract(json, '$.toSessionId') = ?
274
+ ORDER BY created_at
275
+ `, sessionId);
276
+ }
277
+ countPendingFollowUps(runId) {
278
+ const row = this.sql(`
279
+ SELECT COUNT(*) AS n FROM task_messages
280
+ WHERE run_id = ? AND state = 'pending' AND json_extract(json, '$.kind') = 'follow_up'
281
+ `).get(runId);
282
+ return row.n;
233
283
  }
234
284
  /** Each beside its run, so the sweep costs one query. A message whose run is
235
285
  * gone comes back with `run: undefined`. */
@@ -274,4 +324,17 @@ export class TaskStore {
274
324
  return run;
275
325
  });
276
326
  }
327
+ /** The continuous conversation's open items, oldest first; `runIds` as main wrote them. */
328
+ openItems() {
329
+ return this.sql("SELECT problem, stage, run_ids FROM open_items ORDER BY updated_at, rowid").all()
330
+ .map((r) => ({ problem: r.problem, stage: r.stage, runIds: JSON.parse(r.run_ids) }));
331
+ }
332
+ /** Main's markers, in reply order, as one write; answers how many rows changed. */
333
+ markOpenItems(markers, now) {
334
+ return this.transact(() => markers.reduce((n, m) => n + Number(m.op === "open"
335
+ ? this.sql(`INSERT INTO open_items(problem, stage, run_ids, updated_at) VALUES (?, ?, ?, ?)
336
+ ON CONFLICT(problem) DO UPDATE SET stage = excluded.stage, run_ids = excluded.run_ids, updated_at = excluded.updated_at`)
337
+ .run(m.problem, m.stage, JSON.stringify(m.runIds), now).changes
338
+ : this.sql("DELETE FROM open_items WHERE problem = ?").run(m.problem).changes), 0));
339
+ }
277
340
  }
@@ -14,3 +14,8 @@ export const isTerminal = (state) => state === "succeeded" ||
14
14
  state === "cancelled" ||
15
15
  state === "interrupted" ||
16
16
  state === "skipped";
17
+ /** The role a fresh run's session is created with and keeps: a lead's, or a
18
+ * worker's when a session launched it; a cron or watch run's has none. */
19
+ export const createdRole = ({ context: { definition: { action } }, invokedBySessionId }) => action.type === "agent" && action.launch?.role === "lead" ? "lead" : invokedBySessionId !== null ? "worker" : undefined;
20
+ /** The `state` of a run an item names that the ledger no longer holds; its `name` is the id. */
21
+ export const NOT_IN_LEDGER = "not in the ledger";
package/dist/tools.js CHANGED
@@ -22,8 +22,8 @@ export const MANAGED = [
22
22
  `post_install = ["rtk", "init", "-g", "--agent", "pi", "--auto-patch"]\n` +
23
23
  `pre_remove = ["rtk", "init", "--uninstall", "--agent", "pi", "--global", "--auto-patch"]`,
24
24
  summary: "Compresses long bash output before it reaches the model. Its install hook " +
25
- "writes its own Pi extension (extensions/rtk.ts, listed under the local " +
26
- "package) — refreshed on every update.",
25
+ "writes its own Pi extension (extensions/rtk.ts), and this switch is that " +
26
+ "extension's too — refreshed on every update.",
27
27
  },
28
28
  {
29
29
  name: "rg",
package/dist/web/auth.js CHANGED
@@ -261,7 +261,7 @@ export function noteFailure(client) {
261
261
  else
262
262
  failures.set(client, { count: 1, resetAt: Date.now() + WINDOW_MS });
263
263
  }
264
- export const forgetFailures = (client) => void failures.delete(client);
264
+ const forgetFailures = (client) => void failures.delete(client);
265
265
  /** Hosts, not schemes: TLS commonly terminates at the reverse proxy. */
266
266
  function originMatches(origin, host) {
267
267
  if (!origin)
@@ -341,7 +341,7 @@ export function openSession(c, store, client) {
341
341
  store.revoke(previous.id);
342
342
  setSessionCookie(c, store.open(client, c.req.header("user-agent") ?? ""));
343
343
  }
344
- export const PASSWORD_OFF = "Password sign-in is off while a passkey is registered.";
344
+ const PASSWORD_OFF = "Password sign-in is off while a passkey is registered.";
345
345
  export function registerAuthRoutes(app, store,
346
346
  /** While it answers true the password is refused and the form offers only
347
347
  * the passkey; read per request, so removing the last passkey needs no restart. */
@@ -2,6 +2,9 @@
2
2
  // public route is registered before auth; every management action is after it.
3
3
  import { logger } from "../log.js";
4
4
  const log = logger("config-sync");
5
+ /** The one answer every route that writes a synced field gives while the
6
+ * subscription is on: the source owns it, and the Console says where. */
7
+ export const managedRefusal = (c) => c.json({ error: "Managed by the configuration subscription — pause it under Agent → Configuration sync to edit here" }, 409);
5
8
  export function registerConfigShareRoute(app, sync) {
6
9
  app.get("/config-sync/:token", async (c) => {
7
10
  c.header("cache-control", "no-store");
@@ -18,13 +21,9 @@ export function registerConfigShareRoute(app, sync) {
18
21
  const published = await sync.published(token);
19
22
  if (!published)
20
23
  return c.json({ error: "not found" }, 404);
21
- c.header("etag", published.etag);
22
24
  c.header("x-content-type-options", "nosniff");
23
- const tags = c.req.header("if-none-match")?.split(",").map((value) => value.trim().replace(/^W\//, ""));
24
- if (tags?.includes(published.etag) || tags?.includes("*"))
25
- return c.body(null, 304);
26
25
  c.header("content-type", "application/json; charset=utf-8");
27
- return c.body(published.body);
26
+ return c.body(published);
28
27
  }
29
28
  catch {
30
29
  // Never let the app's path logger print a capability URL.
@@ -1,9 +1,12 @@
1
1
  // The agent files a scope is configured by, through the ConfigStore. Not via
2
2
  // web/fs.ts: a scope is "global" or a cwd Pi already knows, never a browser path.
3
3
  import { isThinkingLevel } from "../core/types.js";
4
+ import { SNAPSHOT_FILES } from "../agent/types.js";
4
5
  import { normalizeModelRef } from "../settings.js";
6
+ import { managedRefusal } from "./config-sync.js";
5
7
  import { guarded } from "./route.js";
6
- export function registerConfigRoutes(app, { factory, config, onConfigWritten }) {
8
+ export function registerConfigRoutes(app, { factory, config, onConfigWritten, subscribed = () => false }) {
9
+ const managed = (scope, name) => scope.kind === "global" && subscribed() && SNAPSHOT_FILES.includes(name);
7
10
  // Only cwds Pi already knows are accepted — never an arbitrary path.
8
11
  const parseScope = async (raw) => {
9
12
  if (!raw || raw === "global")
@@ -19,7 +22,7 @@ export function registerConfigRoutes(app, { factory, config, onConfigWritten })
19
22
  return c.json({
20
23
  // Where this scope's files live on disk — the UI labels "Global" with it.
21
24
  dir: scope.kind === "global" ? config.globalDir : scope.cwd,
22
- files: await config.listFiles(scope),
25
+ files: (await config.listFiles(scope)).map((file) => ({ ...file, managed: managed(scope, file.name) })),
23
26
  });
24
27
  });
25
28
  guarded(app, "GET", "/api/config/files/:name", 400, async (c) => {
@@ -38,6 +41,8 @@ export function registerConfigRoutes(app, { factory, config, onConfigWritten })
38
41
  return c.json({ error: "content and expected content required" }, 400);
39
42
  }
40
43
  const name = c.req.param("name");
44
+ if (managed(scope, name))
45
+ return managedRefusal(c);
41
46
  await config.writeFile(scope, name, body.content, body.expected);
42
47
  onConfigWritten?.();
43
48
  return c.json({ ok: true, content: await config.readFile(scope, name) });
@@ -48,6 +53,8 @@ export function registerConfigRoutes(app, { factory, config, onConfigWritten })
48
53
  return c.json(await config.readDefaults());
49
54
  });
50
55
  guarded(app, "PUT", "/api/config/defaults", 400, async (c) => {
56
+ if (subscribed())
57
+ return managedRefusal(c);
51
58
  const defaults = parseDefaults(await c.req.json().catch(() => null));
52
59
  if (!defaults) {
53
60
  return c.json({ error: "defaultModel must be {provider, id} or null; defaultThinkingLevel a reasoning level or null" }, 400);
@@ -1,4 +1,4 @@
1
- // What git knows about a project directory, for the Files view. Every route
1
+ // What git knows about a project directory, for the Files dialog. Every route
2
2
  // runs git and nothing else; reading files is web/fs.ts.
3
3
  import { execFile } from "node:child_process";
4
4
  import { promisify } from "node:util";
@@ -11,7 +11,7 @@ const REF_RE = /^[^-\s][^\s]*$/;
11
11
  /** Output capped: a diff is display data, not an archive. */
12
12
  const git = async (root, ...args) => (await run("git", ["-C", root, ...args], { maxBuffer: MAX_DIFF_BYTES })).stdout;
13
13
  export function registerExplorerRoutes(app) {
14
- // Not a repo → { branch: null }: the Files view draws no compare, no filter.
14
+ // Not a repo → { branch: null }: the Files dialog draws no compare, no filter.
15
15
  guarded(app, "GET", "/api/explorer/git", 404, async (c) => {
16
16
  c.header("cache-control", "no-store");
17
17
  const root = await scoped(c.req.query("root"));