@sigrun/escalations 0.1.1 → 0.2.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/CHANGELOG.md CHANGED
@@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [0.2.1] - 2026-11-06
11
+
12
+ ### Changed
13
+
14
+ - The Inbox is one split page: the tab's list in the sidebar (tabs stay
15
+ in the header), the selected escalation in main, with its actions on
16
+ a header callout and a `Viewing` badge on the row being read. Params
17
+ are `{tab?, id}`: an id with no tab lists the item's own tab (notice
18
+ and tool links), opening a row keeps the current tab, and compact
19
+ widths use the page selection drill-in (list first, back to the tab).
20
+ An unknown or invisible id keeps the caller's list and shows "not
21
+ found" in main (identical for both — no title leak).
22
+
23
+ ## [0.2.0] - 2026-11-05
24
+
25
+ ### Added
26
+
27
+ - Published hooks `escalation.raise`, `escalation.take` and
28
+ `escalation.resolve`: the extension-to-extension seam over the same
29
+ store and lifecycle as the `escalate` tool. `take` claims an ANSWERED
30
+ item for the calling conversation (idempotent per conversation,
31
+ caller's bucket only); `resolve` needs the claiming conversation
32
+ (wrong_claimant propagates). The warden is the first consumer
33
+ (one-shot job grants).
34
+
8
35
  ## [0.1.1] - 2026-11-03
9
36
  ### Fixed
10
37
  - `reopenReason` is cleared on forward transitions (`resolve` done,
@@ -28,7 +55,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
28
55
  - The factory accepts an optional `{ now }` clock for tests (arity one
29
56
  is preserved for the loader).
30
57
 
31
- ## [Unreleased]
58
+ ## [0.1.0] - 2026-11-02
59
+
32
60
  ### Added
33
61
  - The escalation extension factory with `escalate`, `escalation_list`, and
34
62
  `escalation_resolve` tools, prompt guidance, a four-tab Inbox, immediate
package/README.md CHANGED
@@ -44,6 +44,30 @@ Resolves one item claimed by the current conversation:
44
44
 
45
45
  `done` moves the item to **Resolved**. `failed` returns it to **Open**, clears its answer and claim, and records that the action failed. Only the claiming conversation may resolve the item.
46
46
 
47
+ ## Published hooks
48
+
49
+ The extension publishes three hooks for other extensions through core's published-hook registry (`host.publishedHooks`). All three require a call context with a `conversationId`; without one the call fails with `escalation hooks need a conversation`.
50
+
51
+ The namespace is `job:<jobId>` when the payload passes a `jobId`, else the calling conversation's `meta.jobId`, else `"user"`. The bucket comes from the calling conversation's owner, exactly as the agent tools do. All writes go through the store's serialized `update`.
52
+
53
+ ### `escalation.raise`
54
+
55
+ Input: `{ key, title, body, options, allowInstruction?, onAnswer?, jobId? }` with the same bounds as the `escalate` tool (`key` and `title` ≤ 200 chars, `body` ≤ 20000, 1–8 options with unique ids). Output: `{ id, created, status }`.
56
+
57
+ Behavior mirrors `escalate`: an unresolved item with the same namespace and key is returned with `created: false` instead of duplicating; a new item notifies its owner. `onAnswer` defaults to `next-run` in a job namespace and `immediate` otherwise; `next-run` without any job context throws. `allowInstruction` defaults to `false`.
58
+
59
+ ### `escalation.take`
60
+
61
+ Input: `{ key, jobId? }`. Output: `{ status, id?, option? }`.
62
+
63
+ Finds the unresolved item with the given namespace and key. When none exists, returns `{ status: "none" }`. An **answered** item is claimed for the calling conversation, returning `{ status: "claimed", id, option }` with the answer's option. The take is idempotent for the claiming conversation; another conversation (or an **open** item) gets the item's current status without claiming anything.
64
+
65
+ ### `escalation.resolve`
66
+
67
+ Input: `{ id, outcome: "done" | "failed", note? }` (`note` defaults to `""`). Output: `{ status }`.
68
+
69
+ Finds the item by id and resolves it through the claiming conversation. Only the claiming conversation may resolve; a wrong claimant or an unclaimed item fails with the lifecycle transition code (`wrong_claimant` / `not_claimed`) as the error message.
70
+
47
71
  ## Next-run versus immediate dispatch
48
72
 
49
73
  For a job conversation, the default is **`next-run`**: the answer waits until a later scheduled run, when `escalation_list` claims it for action. `next-run` is valid only for an item raised by a scheduled job.
@@ -0,0 +1,4 @@
1
+ import type { PublishedHook } from "@sigrun/sigrun/extension";
2
+ import type { DispatchDeps } from "./dispatch.js";
3
+ /** Build the three published escalation hooks over one shared store. */
4
+ export declare function createPublishedHooks(deps: DispatchDeps): readonly PublishedHook[];
package/dist/hooks.js ADDED
@@ -0,0 +1,192 @@
1
+ import { Type } from "@earendil-works/pi-ai";
2
+ import { changedInbox } from "./keys.js";
3
+ import { TransitionError, claim, raise, resolve } from "./lifecycle.js";
4
+ import { notifyRaised, notifyReopened } from "./notify.js";
5
+ import { bucketFor } from "./store.js";
6
+ // Bounds mirror the escalate tool's parameters (tools.ts OptionSchema).
7
+ const OptionSchema = Type.Object({
8
+ id: Type.String({
9
+ minLength: 1,
10
+ maxLength: 64,
11
+ pattern: "^[a-z0-9][a-z0-9_-]{0,63}$",
12
+ }),
13
+ label: Type.String({ minLength: 1, maxLength: 200 }),
14
+ detail: Type.Optional(Type.String({ maxLength: 2000 })),
15
+ });
16
+ const KeySchema = Type.String({ minLength: 1, maxLength: 200 });
17
+ const JobIdSchema = Type.Optional(Type.String({ minLength: 1 }));
18
+ const StatusSchema = Type.String();
19
+ async function context(deps, call, jobId) {
20
+ if (call.conversationId === undefined) {
21
+ throw new Error("escalation hooks need a conversation");
22
+ }
23
+ const meta = await deps.host.conversations.meta(call.conversationId);
24
+ const effectiveJobId = jobId ?? meta.jobId;
25
+ return {
26
+ namespace: effectiveJobId === undefined ? "user" : `job:${effectiveJobId}`,
27
+ bucket: await bucketFor(deps.host, meta.owner),
28
+ };
29
+ }
30
+ /** Build the three published escalation hooks over one shared store. */
31
+ export function createPublishedHooks(deps) {
32
+ const now = deps.now ?? (() => new Date());
33
+ const raiseHook = {
34
+ name: "escalation.raise",
35
+ description: "Raise an asynchronous decision for the user. Dedups by namespace+key among unresolved items.",
36
+ input: Type.Object({
37
+ key: KeySchema,
38
+ title: Type.String({ minLength: 1, maxLength: 200 }),
39
+ body: Type.String({ minLength: 1, maxLength: 20_000 }),
40
+ options: Type.Array(OptionSchema, { minItems: 1, maxItems: 8 }),
41
+ allowInstruction: Type.Optional(Type.Boolean({ default: false })),
42
+ onAnswer: Type.Optional(Type.Union([
43
+ Type.Literal("next-run"),
44
+ Type.Literal("immediate"),
45
+ ])),
46
+ jobId: JobIdSchema,
47
+ }),
48
+ output: Type.Object({
49
+ id: Type.String(),
50
+ created: Type.Boolean(),
51
+ status: StatusSchema,
52
+ }),
53
+ async run(payload, call) {
54
+ const args = payload;
55
+ if (call.conversationId === undefined) {
56
+ throw new Error("escalation hooks need a conversation");
57
+ }
58
+ const meta = await deps.host.conversations.meta(call.conversationId);
59
+ const effectiveJobId = args.jobId ?? meta.jobId;
60
+ const namespace = effectiveJobId === undefined ? "user" : `job:${effectiveJobId}`;
61
+ const bucket = await bucketFor(deps.host, meta.owner);
62
+ const onAnswer = args.onAnswer ?? (effectiveJobId === undefined ? "immediate" : "next-run");
63
+ if (onAnswer === "next-run" && effectiveJobId === undefined) {
64
+ throw new Error("escalation.raise: next-run needs a scheduled job run; use immediate");
65
+ }
66
+ const uniqueIds = new Set(args.options.map((option) => option.id));
67
+ if (uniqueIds.size !== args.options.length) {
68
+ throw new Error("escalation.raise: option ids must be unique");
69
+ }
70
+ const owner = bucket.kind === "user" ? bucket.user : undefined;
71
+ // The lookup and insertion share the store's one serialized callback.
72
+ const result = await deps.store.update(bucket, (items) => {
73
+ const existing = items.find((item) => item.namespace === namespace &&
74
+ item.key === args.key &&
75
+ item.status !== "resolved");
76
+ if (existing !== undefined) {
77
+ return { items, result: { item: existing, created: false } };
78
+ }
79
+ const item = raise({
80
+ ...(owner === undefined ? {} : { owner }),
81
+ namespace,
82
+ key: args.key,
83
+ title: args.title,
84
+ body: args.body,
85
+ options: [...args.options],
86
+ allowInstruction: args.allowInstruction ?? false,
87
+ onAnswer,
88
+ source: {
89
+ ...(effectiveJobId === undefined ? {} : { jobId: effectiveJobId }),
90
+ conversationId: call.conversationId,
91
+ },
92
+ }, now());
93
+ return { items: [...items, item], result: { item, created: true } };
94
+ });
95
+ if (result.created) {
96
+ await notifyRaised(deps.host, result.item);
97
+ changedInbox(deps.host, result.item.id);
98
+ }
99
+ return {
100
+ id: result.item.id,
101
+ created: result.created,
102
+ status: result.item.status,
103
+ };
104
+ },
105
+ };
106
+ const takeHook = {
107
+ name: "escalation.take",
108
+ description: "Claim an answered escalation for the calling conversation, or report its current status.",
109
+ input: Type.Object({ key: KeySchema, jobId: JobIdSchema }),
110
+ output: Type.Object({
111
+ status: StatusSchema,
112
+ id: Type.Optional(Type.String()),
113
+ option: Type.Optional(Type.String()),
114
+ }),
115
+ async run(payload, call) {
116
+ const args = payload;
117
+ const { namespace, bucket } = await context(deps, call, args.jobId);
118
+ const conversationId = call.conversationId;
119
+ // Take stays in the caller's bucket: another user's item is never
120
+ // claimable (and its runSettled release sweep must not reach it).
121
+ const found = await deps.store.update(bucket, (items) => {
122
+ const item = items.find((entry) => entry.namespace === namespace &&
123
+ entry.key === args.key &&
124
+ entry.status !== "resolved");
125
+ if (item === undefined)
126
+ return { items, result: undefined };
127
+ if (item.status === "answered" ||
128
+ (item.status === "claimed" && item.claim?.conversationId === conversationId)) {
129
+ const claimed = claim(item, conversationId, now());
130
+ // Idempotent re-claim returns the item unchanged: keep the
131
+ // original array so the store's no-write fast path applies.
132
+ const next = claimed === item
133
+ ? items
134
+ : items.map((entry) => (entry === item ? claimed : entry));
135
+ return {
136
+ items: next,
137
+ result: {
138
+ status: "claimed",
139
+ id: claimed.id,
140
+ ...(claimed.answer?.option === undefined
141
+ ? {}
142
+ : { option: claimed.answer.option }),
143
+ },
144
+ };
145
+ }
146
+ return {
147
+ items,
148
+ result: {
149
+ status: item.status,
150
+ id: item.id,
151
+ ...(item.answer?.option === undefined
152
+ ? {}
153
+ : { option: item.answer.option }),
154
+ },
155
+ };
156
+ });
157
+ return found ?? { status: "none" };
158
+ },
159
+ };
160
+ const resolveHook = {
161
+ name: "escalation.resolve",
162
+ description: "Resolve an escalation claimed by the calling conversation after carrying out its answer.",
163
+ input: Type.Object({
164
+ id: Type.String({ minLength: 1 }),
165
+ outcome: Type.Union([Type.Literal("done"), Type.Literal("failed")]),
166
+ note: Type.Optional(Type.String({ maxLength: 4000, default: "" })),
167
+ }),
168
+ output: Type.Object({ status: StatusSchema }),
169
+ async run(payload, call) {
170
+ const args = payload;
171
+ const { bucket } = await context(deps, call, undefined);
172
+ const conversationId = call.conversationId;
173
+ // A TransitionError (wrong claimant / not claimed) propagates: the
174
+ // caller sees `failed` with the code as the message.
175
+ const item = await deps.store.update(bucket, (items) => {
176
+ const index = items.findIndex((candidate) => candidate.id === args.id);
177
+ if (index < 0)
178
+ throw new TransitionError("not_found");
179
+ const resolved = resolve(items[index], conversationId, args.outcome, args.note ?? "", now());
180
+ const next = [...items];
181
+ next[index] = resolved;
182
+ return { items: next, result: resolved };
183
+ });
184
+ // Mirrors escalation_resolve: a failed claim reopens for the owner.
185
+ if (args.outcome === "failed")
186
+ await notifyReopened(deps.host, item);
187
+ changedInbox(deps.host, item.id);
188
+ return { status: item.status };
189
+ },
190
+ };
191
+ return [raiseHook, takeHook, resolveHook];
192
+ }
package/dist/inbox.d.ts CHANGED
@@ -8,12 +8,17 @@
8
8
  * The open tab sorts reopened items first, then newest; resolved is
9
9
  * newest first. An answered `next-run` item whose job is disabled
10
10
  * shows the badge `Waiting: job disabled`.
11
- * - Detail mode is a page titled with the escalation title: markdown
12
- * body, answer form (options in the agent's order, NONE marked
11
+ * - Split layout: the list is the page sidebar (tabs in the header),
12
+ * the selected item (params `{tab?, id}`) fills main, and the row
13
+ * being viewed carries a `Viewing` badge. An id with no tab lists the
14
+ * item's own tab; on compact widths the page's selection drill-in
15
+ * shows the list first.
16
+ * - The detail (main) is a header callout with the item's actions, the
17
+ * markdown body, answer form (options in the agent's order, NONE marked
13
18
  * recommended — the webui preselects recommended options and the
14
19
  * proposed action may be destructive), snooze form seeded at 7 days
15
- * (number fields have no `default` prop), Dismiss/Withdraw/Restore as
16
- * page actions, conversation links, and the history as a log block.
20
+ * (number fields have no `default` prop), Dismiss/Withdraw/Restore and
21
+ * conversation links on the header callout, and the history as a log.
17
22
  * Orphaned items show only the pinned warn callout and Dismiss.
18
23
  * - Every action resolves the item through `findVisible` over the
19
24
  * caller's buckets, so another user's id behaves exactly like an
package/dist/inbox.js CHANGED
@@ -8,12 +8,17 @@
8
8
  * The open tab sorts reopened items first, then newest; resolved is
9
9
  * newest first. An answered `next-run` item whose job is disabled
10
10
  * shows the badge `Waiting: job disabled`.
11
- * - Detail mode is a page titled with the escalation title: markdown
12
- * body, answer form (options in the agent's order, NONE marked
11
+ * - Split layout: the list is the page sidebar (tabs in the header),
12
+ * the selected item (params `{tab?, id}`) fills main, and the row
13
+ * being viewed carries a `Viewing` badge. An id with no tab lists the
14
+ * item's own tab; on compact widths the page's selection drill-in
15
+ * shows the list first.
16
+ * - The detail (main) is a header callout with the item's actions, the
17
+ * markdown body, answer form (options in the agent's order, NONE marked
13
18
  * recommended — the webui preselects recommended options and the
14
19
  * proposed action may be destructive), snooze form seeded at 7 days
15
- * (number fields have no `default` prop), Dismiss/Withdraw/Restore as
16
- * page actions, conversation links, and the history as a log block.
20
+ * (number fields have no `default` prop), Dismiss/Withdraw/Restore and
21
+ * conversation links on the header callout, and the history as a log.
17
22
  * Orphaned items show only the pinned warn callout and Dismiss.
18
23
  * - Every action resolves the item through `findVisible` over the
19
24
  * caller's buckets, so another user's id behaves exactly like an
@@ -32,6 +37,16 @@ const TABS = [
32
37
  { id: "resolved", label: "Resolved" },
33
38
  ];
34
39
  const ACTIVE_STATUSES = ["answered", "claimed", "snoozed"];
40
+ /** The tab an item of this status lists under. */
41
+ function tabOf(status) {
42
+ if (status === "open")
43
+ return "open";
44
+ if (ACTIVE_STATUSES.includes(status))
45
+ return "active";
46
+ if (status === "dismissed")
47
+ return "dismissed";
48
+ return "resolved";
49
+ }
35
50
  function isObject(payload) {
36
51
  return typeof payload === "object" && payload !== null && !Array.isArray(payload);
37
52
  }
@@ -182,6 +197,8 @@ export function createInboxView(deps) {
182
197
  // Empty badge text is filtered by the channel, so the waiting
183
198
  // badge shows only on rows that carry it.
184
199
  { text: { $: "./waitingBadge" }, tone: "warn" },
200
+ // Marks the row whose detail fills main (split layout).
201
+ { text: { $: "./viewing" }, tone: "accent" },
185
202
  ],
186
203
  meta: [{ time: { $: "./updatedAt" }, format: "relative" }],
187
204
  };
@@ -205,8 +222,9 @@ export function createInboxView(deps) {
205
222
  onActivate: { type: "submit", name: "open" },
206
223
  };
207
224
  }
208
- function listTree(tab, error) {
209
- const main = error === undefined
225
+ /** The list column: the tab's collection, or the store-read error. */
226
+ function sidebarBlocks(tab, error) {
227
+ return error === undefined
210
228
  ? [collectionFor(tab)]
211
229
  : [
212
230
  {
@@ -217,41 +235,54 @@ export function createInboxView(deps) {
217
235
  body: error,
218
236
  },
219
237
  ];
238
+ }
239
+ /** Main with nothing selected. */
240
+ function placeholderMain() {
241
+ return [
242
+ {
243
+ type: "markdown",
244
+ id: "inbox-select",
245
+ text: "Select an escalation from the list to read it and answer.",
246
+ },
247
+ ];
248
+ }
249
+ /** Main for an unknown id: identical for unknown and for invisible ids. */
250
+ function notFoundMain() {
251
+ return [
252
+ {
253
+ type: "callout",
254
+ id: "inbox-not-found-callout",
255
+ tone: "bad",
256
+ title: "Escalation not found",
257
+ },
258
+ ];
259
+ }
260
+ /**
261
+ * The split page: tabs in the header, the list in the sidebar, the
262
+ * selection (or a placeholder) in main. On compact widths the
263
+ * `selection` drill-in shows the list first; back returns to the tab.
264
+ */
265
+ function pageTree(tab, sidebar, main, selected) {
220
266
  return {
221
267
  v: 1,
222
268
  root: {
223
269
  type: "page",
224
270
  id: "inbox-root",
225
271
  title: "Inbox",
272
+ subtitle: TABS.find((entry) => entry.id === tab)?.label ?? "",
226
273
  actions: TABS.map((entry) => ({
227
274
  id: `tab-${entry.id}`,
228
275
  label: entry.label,
229
276
  action: { type: "navigate", params: { tab: entry.id } },
230
277
  })),
278
+ sidebar,
231
279
  main,
280
+ selection: { selected, back: { tab } },
232
281
  },
233
282
  };
234
283
  }
235
- /** The not-found page: identical for unknown and for invisible ids. */
236
- function notFoundTree() {
237
- return {
238
- v: 1,
239
- root: {
240
- type: "page",
241
- id: "inbox-not-found",
242
- title: "Inbox",
243
- main: [
244
- {
245
- type: "callout",
246
- id: "inbox-not-found-callout",
247
- tone: "bad",
248
- title: "Escalation not found",
249
- },
250
- ],
251
- },
252
- };
253
- }
254
- function detailTree(item, jobName) {
284
+ /** Main for one selected item: header card with its actions, body, forms, history. */
285
+ function detailMain(item, jobName) {
255
286
  const orphaned = item.orphaned === true;
256
287
  const actions = [];
257
288
  if (item.status === "open" || item.status === "answered") {
@@ -303,7 +334,16 @@ export function createInboxView(deps) {
303
334
  },
304
335
  });
305
336
  }
306
- const main = [];
337
+ const main = [
338
+ {
339
+ type: "callout",
340
+ id: "inbox-item",
341
+ tone: "neutral",
342
+ title: item.title,
343
+ body: `${sourceLabel(item, jobName)} · ${item.status}`,
344
+ actions,
345
+ },
346
+ ];
307
347
  if (orphaned) {
308
348
  main.push({
309
349
  type: "callout",
@@ -369,17 +409,7 @@ export function createInboxView(deps) {
369
409
  lines: historyLines(item),
370
410
  wrap: true,
371
411
  });
372
- return {
373
- v: 1,
374
- root: {
375
- type: "page",
376
- id: "inbox-detail",
377
- title: item.title,
378
- subtitle: sourceLabel(item, jobName),
379
- actions,
380
- main,
381
- },
382
- };
412
+ return main;
383
413
  }
384
414
  /** The shared action preamble: resolve the item inside the caller's buckets. */
385
415
  async function visibleItem(ctx, payload) {
@@ -475,29 +505,33 @@ export function createInboxView(deps) {
475
505
  access: "none",
476
506
  };
477
507
  }
478
- const id = typeof ctx.params.id === "string" ? ctx.params.id : undefined;
479
- if (id !== undefined) {
480
- const found = await findVisible(store, buckets, id);
481
- if (found === undefined) {
482
- // Nothing here can be acted on, so the page is not writable.
483
- return { tree: notFoundTree(), data: {}, access: "none" };
484
- }
485
- const job = await jobOf(found.item);
486
- return {
487
- tree: detailTree(found.item, job?.name),
488
- data: { answer: {}, snooze: { days: 7 } },
489
- access: "write",
490
- };
491
- }
508
+ const id = typeof ctx.params.id === "string" && ctx.params.id !== ""
509
+ ? ctx.params.id
510
+ : undefined;
511
+ const found = id === undefined ? undefined : await findVisible(store, buckets, id);
492
512
  const tabParam = ctx.params.tab;
513
+ // An id with no tab (a notice or tool link) opens the list the
514
+ // item lives in.
493
515
  const tab = TABS.some((entry) => entry.id === tabParam)
494
516
  ? tabParam
495
- : "open";
517
+ : found !== undefined
518
+ ? tabOf(found.item.status)
519
+ : "open";
520
+ let main;
521
+ if (id === undefined)
522
+ main = placeholderMain();
523
+ else if (found === undefined)
524
+ main = notFoundMain();
525
+ else
526
+ main = detailMain(found.item, (await jobOf(found.item))?.name);
527
+ const selected = id !== undefined;
528
+ const detailData = found === undefined ? {} : { answer: {}, snooze: { days: 7 } };
496
529
  try {
497
530
  const rows = (await Promise.all(buckets.map(rowsFor))).flat();
498
- const data = { items: sortForTab(rows, tab) };
531
+ const items = sortForTab(rows, tab).map((row) => found !== undefined && row.id === found.item.id ? { ...row, viewing: "Viewing" } : row);
532
+ const data = { items, ...detailData };
499
533
  return {
500
- tree: listTree(tab),
534
+ tree: pageTree(tab, sidebarBlocks(tab), main, selected),
501
535
  // SAFETY: rows are a JSON mirror (plain literals), same cast
502
536
  // the curator view uses for its data document.
503
537
  data: data,
@@ -506,20 +540,27 @@ export function createInboxView(deps) {
506
540
  }
507
541
  catch (error) {
508
542
  return {
509
- tree: listTree(tab, errorText(error)),
510
- data: { error: errorText(error) },
543
+ tree: pageTree(tab, sidebarBlocks(tab, errorText(error)), main, selected),
544
+ data: { error: errorText(error), ...detailData },
511
545
  access: "write",
512
546
  };
513
547
  }
514
548
  },
515
549
  actions: {
516
- open: async (_ctx, payload) => {
550
+ open: async (ctx, payload) => {
517
551
  const id = itemIdOf(payload);
518
552
  if (id === undefined)
519
553
  return notFound();
554
+ // Keep the list the row was opened from beside the selection.
555
+ const tab = TABS.some((entry) => entry.id === ctx.params.tab)
556
+ ? ctx.params.tab
557
+ : undefined;
520
558
  return {
521
559
  ok: true,
522
- navigate: { type: "navigate", params: { id } },
560
+ navigate: {
561
+ type: "navigate",
562
+ params: tab === undefined ? { id } : { tab, id },
563
+ },
523
564
  };
524
565
  },
525
566
  answer: async (ctx, payload) => await actOn(ctx, payload, async ({ bucket, item }) => {
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { defineExtension } from "@earendil-works/pi-durable";
2
2
  import { createRunSettled } from "./dispatch.js";
3
+ import { createPublishedHooks } from "./hooks.js";
3
4
  import { createInboxView } from "./inbox.js";
4
5
  import { escalationsSection } from "./section.js";
5
6
  import { EscalationStore } from "./store.js";
@@ -22,6 +23,7 @@ export default function createEscalations(host, options = {}) {
22
23
  }),
23
24
  jobs: [SWEEP_JOB],
24
25
  runSettled: createRunSettled(deps),
26
+ publishedHooks: createPublishedHooks(deps),
25
27
  surfaces: { views: [createInboxView(deps)] },
26
28
  userDeleted: async (event) => {
27
29
  const source = { kind: "user", user: event.user };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sigrun/escalations",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Escalation inbox extension for sigrun: async decisions for jobs and agents",
5
5
  "type": "module",
6
6
  "engines": {