opencode-courier 0.5.0 → 0.7.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 (43) hide show
  1. package/README.md +39 -30
  2. package/dist/index.js +9 -5
  3. package/dist/person/form.d.ts +4 -1
  4. package/dist/person/form.js +5 -0
  5. package/dist/person/index.d.ts +2 -1
  6. package/dist/person/index.js +1 -0
  7. package/dist/person/permission.d.ts +14 -0
  8. package/dist/person/permission.js +15 -0
  9. package/dist/person/ports.d.ts +8 -2
  10. package/dist/person/rpc-handlers.d.ts +37 -30
  11. package/dist/person/rpc-handlers.js +26 -16
  12. package/dist/person/rpc.d.ts +43 -24
  13. package/dist/person/rpc.js +4 -3
  14. package/dist/person/tui.d.ts +23 -12
  15. package/dist/tree/domain.d.ts +23 -0
  16. package/dist/tree/domain.js +26 -0
  17. package/dist/tree/events.d.ts +2 -2
  18. package/dist/tree/events.js +31 -4
  19. package/dist/tree/index.d.ts +2 -1
  20. package/dist/tree/index.js +2 -1
  21. package/dist/tree/panel.d.ts +7 -2
  22. package/dist/tree/panel.js +29 -17
  23. package/dist/tree/ports.d.ts +36 -0
  24. package/dist/tree/roster.d.ts +5 -0
  25. package/dist/tree/roster.js +5 -3
  26. package/dist/tree/rpc-handlers.d.ts +55 -6
  27. package/dist/tree/rpc-handlers.js +30 -1
  28. package/dist/tree/rpc.d.ts +111 -22
  29. package/dist/tree/rpc.js +30 -9
  30. package/dist/tree/stop.js +2 -0
  31. package/dist/tree/store.d.ts +4 -1
  32. package/dist/tree/store.js +51 -0
  33. package/dist/tree/subtree.d.ts +40 -9
  34. package/dist/tree/subtree.js +40 -19
  35. package/dist/tree/summary.d.ts +2 -2
  36. package/dist/tree/summary.js +17 -16
  37. package/dist/tree/text.js +4 -1
  38. package/dist/tree/tools.js +2 -1
  39. package/dist/tree/tui.d.ts +17 -6
  40. package/dist/tree/wake.d.ts +68 -0
  41. package/dist/tree/wake.js +119 -0
  42. package/dist/tui.js +617 -197
  43. package/package.json +1 -1
package/dist/tui.js CHANGED
@@ -18,7 +18,8 @@ var Permission = Schema.Struct({
18
18
  requestID: Schema.String,
19
19
  action: Schema.String,
20
20
  resources: Schema.Array(Schema.String),
21
- save: Schema.optionalKey(Schema.Array(Schema.String))
21
+ save: Schema.optionalKey(Schema.Array(Schema.String)),
22
+ passesOn: Schema.optionalKey(Schema.Struct({ sessionID: Schema.String, requestID: Schema.String }))
22
23
  });
23
24
  var Prompt = Schema.Struct({
24
25
  question: Schema.String,
@@ -36,8 +37,7 @@ var Asks = Schema.Struct({
36
37
  top: Schema.String,
37
38
  sessions: Schema.Array(Session),
38
39
  permissions: Schema.Array(Permission),
39
- questions: Schema.Array(Question),
40
- truncated: Schema.optionalKey(Count)
40
+ questions: Schema.Array(Question)
41
41
  });
42
42
  var Answered = Schema.Struct({
43
43
  answered: Schema.Boolean,
@@ -66,12 +66,22 @@ var REJECTED = "Refused by the session that started you. Do without it, or repor
66
66
  var QUESTION_FORM = "question";
67
67
  var INBOX_COMMAND = "courier.asks";
68
68
  var SETTLE_MS = 250;
69
+ var topSession = (asks) => ({
70
+ sessionID: asks.top,
71
+ title: "",
72
+ parentID: "",
73
+ depth: 0
74
+ });
69
75
  function asksOf(asks, forms) {
70
- return asks.sessions.flatMap((session) => [...asks.permissions.filter((request) => request.sessionID === session.sessionID).map((request) => ({
76
+ const prompting = new Set(asks.permissions.flatMap((request) => request.sessionID === asks.top && request.passesOn ? [request.passesOn.requestID] : []));
77
+ return [topSession(asks), ...asks.sessions].flatMap((session) => [...asks.permissions.filter((request) => request.sessionID === session.sessionID).map((request) => ({
71
78
  kind: "permission",
72
79
  key: `permission ${request.requestID}`,
73
80
  session,
74
- request
81
+ request,
82
+ ...session.depth > 0 && prompting.has(request.requestID) ? {
83
+ prompted: true
84
+ } : {}
75
85
  })), ...asks.questions.filter((question) => question.sessionID === session.sessionID).map((question) => ({
76
86
  kind: "question",
77
87
  key: `question ${question.requestID}`,
@@ -84,11 +94,13 @@ function asksOf(asks, forms) {
84
94
  form
85
95
  }))]);
86
96
  }
87
- function pathOf(asks, sessionID, topTitle) {
97
+ function pathOf(asks, sessionID, topTitle, currentTitle = () => {
98
+ return;
99
+ }) {
88
100
  const byID = new Map(asks.sessions.map((session) => [session.sessionID, session]));
89
101
  const titles = [];
90
102
  for (let at = byID.get(sessionID);at && titles.length <= byID.size; at = byID.get(at.parentID))
91
- titles.unshift(at.title);
103
+ titles.unshift(currentTitle(at.sessionID) || at.title);
92
104
  return [topTitle, ...titles].join(" \u203A ");
93
105
  }
94
106
  var clip = (text, max = 80) => text.length > max ? `${text.slice(0, max - 1)}\u2026` : text;
@@ -97,9 +109,13 @@ function askTitle(ask) {
97
109
  case "permission": {
98
110
  const {
99
111
  action,
100
- resources
112
+ resources,
113
+ passesOn
101
114
  } = ask.request;
102
- return clip(resources.length ? `Permission: ${action} ${resources.join(", ")}` : `Permission: ${action}`);
115
+ const what = resources.length ? `${action} ${resources.join(", ")}` : action;
116
+ if (ask.session.depth > 0)
117
+ return clip(`Permission: ${what}`);
118
+ return clip(passesOn ? `Permission (top session): courier_answer once for "${what}"` : `Permission (top session): ${what}`);
103
119
  }
104
120
  case "question": {
105
121
  const [first, ...rest] = ask.question.questions;
@@ -112,14 +128,89 @@ function askTitle(ask) {
112
128
  }
113
129
  }
114
130
  function askFooter(ask) {
115
- return [`depth ${ask.session.depth}`, ask.session.group ? `group ${ask.session.group}` : "", ask.kind === "question" && ask.question.stopped ? "cut off, answered by message" : ""].filter(Boolean).join(" \xB7 ");
131
+ const top = ask.session.depth === 0;
132
+ return [top ? "top session" : `depth ${ask.session.depth}`, ask.session.group ? `group ${ask.session.group}` : "", ask.kind === "permission" && ask.prompted ? "the top's prompt asks" : "", ask.kind === "question" && ask.question.stopped ? "cut off, answered by message" : ""].filter(Boolean).join(" \xB7 ");
116
133
  }
117
- var soundOf = (ask) => ask.kind === "permission" ? "permission" : "question";
118
- function shown(field, answers) {
119
- if ("hidden" in field && field.hidden)
120
- return false;
134
+ function active(field, answers) {
121
135
  const when = "when" in field ? field.when ?? [] : [];
122
- return when.every((condition) => condition.op === "eq" ? answers[condition.key] === condition.value : answers[condition.key] !== condition.value);
136
+ return when.every(({
137
+ key,
138
+ op,
139
+ value
140
+ }) => {
141
+ const answer = answers[key];
142
+ if (answer === undefined)
143
+ return false;
144
+ const hit = Array.isArray(answer) ? answer.some((item) => item === value) : answer === value;
145
+ return op === "eq" ? hit : !hit;
146
+ });
147
+ }
148
+ var EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
149
+ var isDate = (text) => /^\d{4}-\d{2}-\d{2}$/.test(text) && new Date(`${text}T00:00:00.000Z`).toISOString().slice(0, 10) === text;
150
+ function misfit(field, value) {
151
+ if (field.type === "string") {
152
+ if (typeof value !== "string")
153
+ return "Expected text";
154
+ if (field.required && !value)
155
+ return "Answer required";
156
+ if (field.minLength !== undefined && value.length < field.minLength)
157
+ return `Must be at least ${field.minLength} characters`;
158
+ if (field.maxLength !== undefined && value.length > field.maxLength)
159
+ return `Must be at most ${field.maxLength} characters`;
160
+ if (field.pattern !== undefined) {
161
+ try {
162
+ if (!new RegExp(field.pattern).test(value))
163
+ return `Must match pattern: ${field.pattern}`;
164
+ } catch {
165
+ return `Invalid pattern: ${field.pattern}`;
166
+ }
167
+ }
168
+ if (field.format === "email" && !EMAIL.test(value))
169
+ return "Expected an email address";
170
+ if (field.format === "uri" && !URL.canParse(value))
171
+ return "Expected a URL";
172
+ if (field.format === "date" && !isDate(value))
173
+ return "Expected a date (YYYY-MM-DD)";
174
+ if (field.format === "date-time" && Number.isNaN(new Date(value).getTime()))
175
+ return "Expected a date and time";
176
+ return;
177
+ }
178
+ if (field.type === "number" || field.type === "integer") {
179
+ if (typeof value !== "number" || !Number.isFinite(value))
180
+ return "Expected a number";
181
+ if (field.type === "integer" && !Number.isInteger(value))
182
+ return "Expected an integer";
183
+ if (typeof field.minimum === "number" && value < field.minimum)
184
+ return `Must be at least ${field.minimum}`;
185
+ if (typeof field.maximum === "number" && value > field.maximum)
186
+ return `Must be at most ${field.maximum}`;
187
+ return;
188
+ }
189
+ if (field.type === "multiselect") {
190
+ if (!Array.isArray(value))
191
+ return "Expected selections";
192
+ if (field.required && !value.length)
193
+ return "Select at least one option";
194
+ if (field.minItems !== undefined && value.length < field.minItems)
195
+ return `Select at least ${field.minItems}`;
196
+ if (field.maxItems !== undefined && value.length > field.maxItems)
197
+ return `Select at most ${field.maxItems}`;
198
+ }
199
+ return;
200
+ }
201
+ var numberOf = (limit) => typeof limit === "number" ? limit : undefined;
202
+ function limitsOf(field) {
203
+ const range = (low, high, unit = "") => {
204
+ if (low !== undefined && high !== undefined)
205
+ return `${low}-${high}${unit}`;
206
+ if (low !== undefined)
207
+ return `at least ${low}${unit}`;
208
+ if (high !== undefined)
209
+ return `at most ${high}${unit}`;
210
+ return;
211
+ };
212
+ const parts = field.type === "number" || field.type === "integer" ? [range(numberOf(field.minimum), numberOf(field.maximum))] : field.type === "string" ? [range(field.minLength, field.maxLength, " characters"), field.format, field.pattern ? `/${field.pattern}/` : undefined] : [];
213
+ return parts.filter(Boolean).join(", ") || undefined;
123
214
  }
124
215
  function chooseOptions(choices, picked, {
125
216
  multiple = false,
@@ -163,8 +254,10 @@ function toggle(picked, value) {
163
254
  var OWN = Symbol("own");
164
255
  var DONE = Symbol("done");
165
256
  var SKIP = Symbol("skip");
166
- function addAsksInbox(ctx) {
257
+ var NOTE = Symbol("note");
258
+ function addAsksInbox(ctx, follows) {
167
259
  const person = ctx.client.rpc(personRpc);
260
+ const currentTitle = (sessionID) => ctx.data.session.get(sessionID)?.title;
168
261
  const location = (sessionID) => ctx.data.session.get(sessionID)?.location ?? ctx.location ?? ctx.data.location.default();
169
262
  const openSession = () => {
170
263
  const route = ctx.ui.router.current();
@@ -186,31 +279,46 @@ function addAsksInbox(ctx) {
186
279
  for (const ask of listed)
187
280
  if (!firstSeen.has(ask.key))
188
281
  firstSeen.set(ask.key, seen++);
189
- listed.sort((a, b) => firstSeen.get(a.key) - firstSeen.get(b.key));
282
+ const rank = (ask) => ask.session.depth === 0 ? 0 : 1;
283
+ listed.sort((a, b) => rank(a) - rank(b) || firstSeen.get(a.key) - firstSeen.get(b.key));
190
284
  return {
191
285
  asks,
192
286
  listed
193
287
  };
194
288
  }
289
+ const reason = (error) => {
290
+ if (error instanceof Error)
291
+ return error.message;
292
+ return typeof error === "object" && error && "message" in error ? String(error.message) : String(error);
293
+ };
195
294
  const fail = (what) => (error) => ctx.ui.toast.show({
196
295
  title: "courier",
197
- message: `${what}: ${error instanceof Error ? error.message : String(error)}`,
296
+ message: `${what}: ${reason(error)}`,
198
297
  variant: "error"
199
298
  });
200
299
  async function choose(title, choices, {
201
300
  multiple = false,
202
301
  own = false,
203
- optional = false
302
+ optional = false,
303
+ asks = "",
304
+ start = []
204
305
  } = {}) {
205
- const picked = [];
306
+ const picked = [...start];
206
307
  for (;; ) {
207
308
  const value = await ctx.ui.dialog.select({
208
309
  title,
209
- options: chooseOptions(choices, picked, {
310
+ ...!multiple && start[0] !== undefined ? {
311
+ current: start[0]
312
+ } : {},
313
+ options: [...asks ? [{
314
+ title: asks,
315
+ value: NOTE,
316
+ disabled: true
317
+ }] : [], ...chooseOptions(choices, picked, {
210
318
  multiple,
211
319
  own,
212
320
  optional
213
- })
321
+ })]
214
322
  });
215
323
  if (value === undefined)
216
324
  return;
@@ -313,6 +421,9 @@ function addAsksInbox(ctx) {
313
421
  ...field.description ? {
314
422
  description: field.description
315
423
  } : {},
424
+ ...field.placeholder ? {
425
+ placeholder: field.placeholder
426
+ } : {},
316
427
  ...field.default ? {
317
428
  value: field.default
318
429
  } : {}
@@ -324,11 +435,16 @@ function addAsksInbox(ctx) {
324
435
  }
325
436
  }
326
437
  async function numeric(title, field, optional) {
438
+ const label = field.title ?? field.key;
327
439
  const whole = field.type === "integer";
328
440
  for (;; ) {
329
441
  const text = await typed(title, {
330
442
  ...field.description ? {
331
443
  description: field.description
444
+ } : {},
445
+ ...placeholder(field),
446
+ ...field.default !== undefined ? {
447
+ default: String(field.default)
332
448
  } : {}
333
449
  }, optional);
334
450
  if (text === undefined || text === SKIP)
@@ -338,13 +454,36 @@ function addAsksInbox(ctx) {
338
454
  return value;
339
455
  ctx.ui.toast.show({
340
456
  title: "courier",
341
- message: `${title} takes ${whole ? "a whole number" : "a number"}`,
457
+ message: `${label} takes ${whole ? "a whole number" : "a number"}`,
458
+ variant: "warning"
459
+ });
460
+ }
461
+ }
462
+ const placeholder = (field) => {
463
+ const limits = limitsOf(field);
464
+ return limits ? {
465
+ placeholder: limits
466
+ } : {};
467
+ };
468
+ async function answerField(form, field) {
469
+ for (;; ) {
470
+ const value = await askField(form, field);
471
+ if (value === undefined || value === SKIP)
472
+ return value;
473
+ const problem = misfit(field, value);
474
+ if (!problem)
475
+ return value;
476
+ ctx.ui.toast.show({
477
+ title: "courier",
478
+ message: `${field.title ?? field.key}: ${problem}`,
342
479
  variant: "warning"
343
480
  });
344
481
  }
345
482
  }
346
- async function answerField(field) {
347
- const title = field.title ?? field.key;
483
+ async function askField(form, field) {
484
+ const label = field.title ?? field.key;
485
+ const title = form.title ? `${form.title} \xB7 ${label}` : label;
486
+ const asks = field.description ?? "";
348
487
  const optional = !(("required" in field) && field.required);
349
488
  switch (field.type) {
350
489
  case "external":
@@ -356,7 +495,14 @@ function addAsksInbox(ctx) {
356
495
  case "boolean":
357
496
  return ctx.ui.dialog.select({
358
497
  title,
359
- options: [{
498
+ ...field.default !== undefined ? {
499
+ current: field.default
500
+ } : {},
501
+ options: [...asks ? [{
502
+ title: asks,
503
+ value: NOTE,
504
+ disabled: true
505
+ }] : [], {
360
506
  title: "Yes",
361
507
  value: true
362
508
  }, {
@@ -367,7 +513,9 @@ function addAsksInbox(ctx) {
367
513
  case "multiselect": {
368
514
  const picked = await choose(title, field.options, {
369
515
  multiple: true,
370
- own: !!field.custom
516
+ own: !!field.custom,
517
+ asks,
518
+ start: field.default ?? []
371
519
  });
372
520
  if (picked?.length === 0 && optional)
373
521
  return SKIP;
@@ -375,10 +523,15 @@ function addAsksInbox(ctx) {
375
523
  }
376
524
  case "string": {
377
525
  if (!field.options?.length)
378
- return typed(title, field, optional);
526
+ return typed(title, {
527
+ ...field,
528
+ ...placeholder(field)
529
+ }, optional);
379
530
  const picked = await choose(title, field.options, {
380
531
  own: !!field.custom,
381
- optional
532
+ optional,
533
+ asks,
534
+ start: field.default === undefined ? [] : [field.default]
382
535
  });
383
536
  return picked && (picked[0] ?? SKIP);
384
537
  }
@@ -393,19 +546,36 @@ function addAsksInbox(ctx) {
393
546
  } = ask;
394
547
  const answer2 = {};
395
548
  for (const field of form.fields) {
396
- if (!shown(field, answer2))
549
+ if (!active(field, answer2))
550
+ continue;
551
+ if ("hidden" in field && field.hidden) {
552
+ if ("default" in field && field.default !== undefined)
553
+ answer2[field.key] = field.default;
397
554
  continue;
398
- const value = await answerField(field);
555
+ }
556
+ const value = await answerField(form, field);
399
557
  if (value === undefined)
400
558
  return false;
401
559
  if (value !== SKIP)
402
560
  answer2[field.key] = value;
403
561
  }
404
- await ctx.data.session.form.reply({
405
- sessionID: form.sessionID,
406
- formID: form.id,
407
- answer: answer2
408
- });
562
+ try {
563
+ await ctx.client.session.form.reply({
564
+ sessionID: form.sessionID,
565
+ formID: form.id,
566
+ answer: answer2
567
+ });
568
+ } catch (error) {
569
+ const tag = error?._tag;
570
+ if (tag !== "FormNotFoundError" && tag !== "FormAlreadySettledError")
571
+ throw error;
572
+ ctx.ui.toast.show({
573
+ title: "courier",
574
+ message: "It no longer waits: answered or settled some other way.",
575
+ variant: "warning"
576
+ });
577
+ return false;
578
+ }
409
579
  return true;
410
580
  }
411
581
  const answer = (ask) => {
@@ -418,6 +588,68 @@ function addAsksInbox(ctx) {
418
588
  return answerForm(ask);
419
589
  }
420
590
  };
591
+ let listing;
592
+ let following;
593
+ const keysOf = (listed) => listed.map((ask) => ask.key).join(`
594
+ `);
595
+ let reads = 0;
596
+ const follow = async () => {
597
+ const sessionID = listing?.sessionID;
598
+ if (!sessionID)
599
+ return;
600
+ const ticket = ++reads;
601
+ const found = await read(sessionID);
602
+ const shown = listing;
603
+ if (ticket === reads && shown?.sessionID === sessionID && keysOf(found.listed) !== shown.keys)
604
+ shown.replace(found);
605
+ };
606
+ async function showList(sessionID, {
607
+ asks,
608
+ listed
609
+ }) {
610
+ let replace = () => {
611
+ return;
612
+ };
613
+ const newer = new Promise((resolve) => {
614
+ replace = resolve;
615
+ });
616
+ listing = {
617
+ sessionID,
618
+ keys: keysOf(listed),
619
+ replace
620
+ };
621
+ const top = ctx.data.session.get(asks.top)?.title ?? asks.top;
622
+ const choosing = ctx.ui.dialog.select({
623
+ title: `Waiting on you (${listed.length})`,
624
+ placeholder: "Search",
625
+ options: listed.map((ask) => ({
626
+ title: askTitle(ask),
627
+ value: ask,
628
+ description: pathOf(asks, ask.session.sessionID, top, currentTitle),
629
+ footer: askFooter(ask)
630
+ }))
631
+ });
632
+ ctx.ui.dialog.set({
633
+ size: "large"
634
+ });
635
+ try {
636
+ return await Promise.race([choosing.then((chosen) => ({
637
+ chosen
638
+ })), newer.then((read2) => ({
639
+ read: read2
640
+ }))]);
641
+ } finally {
642
+ listing = undefined;
643
+ }
644
+ }
645
+ async function answerChosen(chosen) {
646
+ const done = await answer(chosen).catch((error) => {
647
+ fail("The answer was not passed on")(error);
648
+ return false;
649
+ });
650
+ if (done)
651
+ answered.add(chosen.key);
652
+ }
421
653
  let opened = false;
422
654
  async function open(sessionID = openSession()) {
423
655
  if (!sessionID) {
@@ -432,83 +664,95 @@ function addAsksInbox(ctx) {
432
664
  return;
433
665
  opened = true;
434
666
  try {
435
- for (let first = true;; first = false) {
436
- const found = await read(sessionID).catch(fail("The asks could not be read"));
437
- if (!found)
438
- return;
439
- const {
440
- asks,
441
- listed
442
- } = found;
443
- if (!listed.length) {
444
- if (first)
445
- ctx.ui.toast.show({
446
- title: "courier",
447
- message: "Nothing in this courier tree waits on you.",
448
- variant: "info"
449
- });
450
- return;
667
+ let found = await read(sessionID).catch(fail("The asks could not be read"));
668
+ let first = true;
669
+ let shown = false;
670
+ while (found?.listed.length) {
671
+ first = false;
672
+ const next = await showList(sessionID, found);
673
+ shown = "read" in next;
674
+ if ("read" in next) {
675
+ found = next.read;
676
+ continue;
451
677
  }
452
- const top = ctx.data.session.get(asks.top)?.title ?? asks.top;
453
- const choosing = ctx.ui.dialog.select({
454
- title: `Waiting on you (${listed.length})`,
455
- placeholder: "Search",
456
- options: listed.map((ask) => ({
457
- title: askTitle(ask),
458
- value: ask,
459
- description: pathOf(asks, ask.session.sessionID, top),
460
- footer: askFooter(ask)
461
- }))
462
- });
463
- ctx.ui.dialog.set({
464
- size: "large"
465
- });
466
- const chosen = await choosing;
467
- if (!chosen)
678
+ if (!next.chosen)
468
679
  return;
469
- const done = await answer(chosen).catch((error) => {
470
- fail("The answer was not passed on")(error);
471
- return false;
472
- });
473
- if (done)
474
- answered.add(chosen.key);
680
+ await answerChosen(next.chosen);
681
+ found = await read(sessionID).catch(fail("The asks could not be read"));
475
682
  }
683
+ if (!found)
684
+ return;
685
+ if (first)
686
+ ctx.ui.toast.show({
687
+ title: "courier",
688
+ message: "Nothing in this courier tree waits on you.",
689
+ variant: "info"
690
+ });
691
+ if (shown)
692
+ ctx.ui.dialog.clear();
476
693
  } finally {
477
694
  opened = false;
695
+ clearTimeout(following);
696
+ following = undefined;
478
697
  }
479
698
  }
480
699
  const sounded = new Set;
700
+ const readTops = new Set;
701
+ let named = new Set;
481
702
  let settling;
482
703
  const sound = async () => {
483
704
  const sessionID = openSession();
484
- if (!sessionID)
705
+ if (!sessionID) {
706
+ named.clear();
485
707
  return;
708
+ }
486
709
  const {
487
710
  asks,
488
711
  listed
489
712
  } = await read(sessionID);
713
+ const eventSessions = named;
714
+ named = new Set;
490
715
  const top = ctx.data.session.get(asks.top)?.title ?? asks.top;
716
+ const first = !readTops.has(asks.top);
717
+ readTops.add(asks.top);
491
718
  for (const ask of listed) {
492
719
  if (sounded.has(ask.key))
493
720
  continue;
494
721
  sounded.add(ask.key);
722
+ if (first && !eventSessions.has(ask.session.sessionID))
723
+ continue;
724
+ if (!ctx.data.session.get(ask.session.sessionID)?.parentID)
725
+ continue;
495
726
  ctx.attention.notify({
496
- title: pathOf(asks, ask.session.sessionID, top),
727
+ title: pathOf(asks, ask.session.sessionID, top, currentTitle),
497
728
  message: askTitle(ask),
498
729
  notification: {
499
730
  when: "blurred"
500
731
  },
501
- sound: {
502
- name: soundOf(ask),
503
- when: "always"
504
- }
732
+ sound: false
505
733
  });
506
734
  }
507
735
  };
736
+ sound().catch(() => {
737
+ return;
738
+ });
508
739
  const unlisten = ctx.data.listen(({
509
740
  details
510
741
  }) => {
511
- if (details.type !== "permission.asked" && details.type !== "form.created" || settling)
742
+ if (listing && follows.has(details.type) && !following)
743
+ following = setTimeout(() => {
744
+ following = undefined;
745
+ follow().catch(() => {
746
+ return;
747
+ });
748
+ }, SETTLE_MS);
749
+ if (details.type !== "permission.asked" && details.type !== "form.created")
750
+ return;
751
+ const data = details.data;
752
+ const eventSession = data?.sessionID ?? data?.form?.sessionID;
753
+ if (eventSession)
754
+ named.add(eventSession);
755
+ if (settling)
512
756
  return;
513
757
  settling = setTimeout(() => {
514
758
  settling = undefined;
@@ -537,6 +781,7 @@ function addAsksInbox(ctx) {
537
781
  open,
538
782
  cleanup: () => {
539
783
  clearTimeout(settling);
784
+ clearTimeout(following);
540
785
  unlisten();
541
786
  }
542
787
  };
@@ -552,7 +797,7 @@ import { createTextNode as _$createTextNode } from "@opentui/solid";
552
797
  import { insertNode as _$insertNode } from "@opentui/solid";
553
798
  import { setProp as _$setProp } from "@opentui/solid";
554
799
  import { createElement as _$createElement } from "@opentui/solid";
555
- import { createEffect, createResource, createRoot, createSignal, For, on, onCleanup, onMount, Show, untrack } from "solid-js";
800
+ import { createEffect, createMemo, createResource, createRoot, createSignal, For, on, onCleanup, onMount, Show, untrack } from "solid-js";
556
801
 
557
802
  // src/tree/rpc.ts
558
803
  import { Rpc as Rpc2 } from "@opencode/plugin/rpc";
@@ -563,20 +808,23 @@ var Summary = Schema2.Struct({
563
808
  running: Count2,
564
809
  waiting: Count2,
565
810
  idle: Count2,
566
- groups: Schema2.Array(Group),
567
- truncated: Schema2.optionalKey(Count2)
811
+ stalled: Schema2.optionalKey(Count2),
812
+ groups: Schema2.Array(Group)
568
813
  });
569
814
  var Wait = Schema2.Union([
570
815
  Schema2.Struct({ type: Schema2.Literal("permission"), action: Schema2.String, resources: Schema2.Array(Schema2.String) }),
571
- Schema2.Struct({ type: Schema2.Literal("question"), question: Schema2.String })
816
+ Schema2.Struct({ type: Schema2.Literal("question"), question: Schema2.String }),
817
+ Schema2.Struct({ type: Schema2.Literal("form"), title: Schema2.String })
572
818
  ]);
573
819
  var Row = Schema2.Struct({
574
820
  sessionID: Schema2.String,
575
821
  parentID: Schema2.String,
576
822
  title: Schema2.String,
577
823
  depth: Count2,
578
- state: Schema2.Literals(["running", "waiting", "idle", "gone"]),
824
+ state: Schema2.Literals(["running", "waiting", "idle", "stalled", "gone"]),
825
+ stalled: Schema2.optionalKey(Schema2.Number),
579
826
  outcome: Schema2.optionalKey(Schema2.Literals(["succeeded", "failed", "interrupted"])),
827
+ stopped: Schema2.optionalKey(Schema2.Boolean),
580
828
  report: Schema2.optionalKey(Schema2.Struct({ state: Schema2.Literals(["owed", "reported", "ended"]), status: Schema2.optionalKey(Schema2.String), progressed: Schema2.Boolean })),
581
829
  group: Schema2.optionalKey(Schema2.Struct({ name: Schema2.String, standing: Schema2.String })),
582
830
  isolated: Schema2.Boolean,
@@ -588,12 +836,17 @@ var Row = Schema2.Struct({
588
836
  });
589
837
  var Tree = Schema2.Struct({
590
838
  rows: Schema2.Array(Row),
591
- truncated: Schema2.optionalKey(Count2)
839
+ truncated: Schema2.optionalKey(Count2),
840
+ stalled: Schema2.optionalKey(Schema2.Number)
592
841
  });
593
842
  var Stopped = Schema2.Struct({
594
843
  stopped: Schema2.Array(Schema2.Struct({ sessionID: Schema2.String, title: Schema2.String, outcome: Schema2.String })),
595
844
  cancelled: Count2
596
845
  });
846
+ var Forgotten = Schema2.Union([
847
+ Schema2.Struct({ outcome: Schema2.Literals(["forgotten", "removed", "gone"]) }),
848
+ Schema2.Struct({ outcome: Schema2.Literal("kept"), reason: Schema2.String })
849
+ ]);
597
850
  var treeRpc = Rpc2.define({
598
851
  id: "courier",
599
852
  methods: {
@@ -609,6 +862,11 @@ var treeRpc = Rpc2.define({
609
862
  input: Schema2.toStandardSchemaV1(Schema2.Struct({ sessionID: Schema2.String, by: Schema2.String })),
610
863
  output: Schema2.toStandardSchemaV1(Stopped),
611
864
  errors: { refused: Schema2.toStandardSchemaV1(Schema2.Struct({})) }
865
+ },
866
+ cleanup: {
867
+ input: Schema2.toStandardSchemaV1(Schema2.Struct({ sessionID: Schema2.String, by: Schema2.String })),
868
+ output: Schema2.toStandardSchemaV1(Forgotten),
869
+ errors: { refused: Schema2.toStandardSchemaV1(Schema2.Struct({})) }
612
870
  }
613
871
  },
614
872
  events: {}
@@ -621,9 +879,19 @@ var SETTLE_MS2 = 250;
621
879
  var PANEL_SETTLE_MS = 1000;
622
880
  var PANEL = "courier-tree";
623
881
  var TICK_MS = 1000;
624
- function summaryLine(summary) {
625
- const parts = [summary.running ? `${summary.running} running` : "", summary.waiting ? `${summary.waiting} waiting on you` : "", summary.idle ? `${summary.idle} idle` : "", ...summary.groups.map((group) => `group ${group.name} ${group.reported}/${group.members}`), summary.truncated ? `first ${summary.truncated} counted` : ""].filter(Boolean);
626
- return parts.length ? `courier ${parts.join(" \xB7 ")}` : "";
882
+ function summaryLine(summary, maxWidth = Infinity) {
883
+ const groups = summary.groups.map((group) => `group ${group.name} ${group.reported}/${group.members}`);
884
+ const parts = [summary.waiting ? `${summary.waiting} waiting on you` : "", summary.stalled ? `${summary.stalled} stalled` : "", summary.running ? `${summary.running} running` : "", ...groups, summary.idle ? `${summary.idle} idle` : ""].filter(Boolean);
885
+ if (!parts.length)
886
+ return "";
887
+ const line = (kept) => {
888
+ const dropped = groups.filter((group) => parts.indexOf(group) >= kept).length;
889
+ return ["courier", parts.slice(0, kept).join(" \xB7 "), dropped ? `\xB7 +${counted(dropped, "group")}` : ""].filter(Boolean).join(" ");
890
+ };
891
+ for (let kept = parts.length;kept > 0; kept--)
892
+ if (line(kept).length <= maxWidth)
893
+ return line(kept);
894
+ return parts[0];
627
895
  }
628
896
  function duration(ms) {
629
897
  const seconds = Math.max(0, Math.floor(ms / 1000));
@@ -649,35 +917,46 @@ function bar({
649
917
  return `[${"\u2588".repeat(filled)}${"\u2591".repeat(width - filled)}] ${reported}/${started} reported`;
650
918
  }
651
919
  function waitLine(wait) {
652
- return wait.type === "permission" ? `asks permission: ${[wait.action, ...wait.resources].join(" ")}` : `asks: ${wait.question}`;
920
+ if (wait.type === "permission")
921
+ return `asks permission: ${[wait.action, ...wait.resources].join(" ")}`;
922
+ return wait.type === "form" ? `shows a form: ${wait.title}` : `asks: ${wait.question}`;
653
923
  }
654
924
  function statusLine(row) {
655
- return [stateWord(row), reportWords(row.report), row.group ? `group ${row.group.name} ${row.group.standing}` : "", row.isolated ? "isolated" : ""].filter(Boolean).join(" \xB7 ");
925
+ return [stateWord(row), reportWords(row.report, row.stopped), row.group ? `group ${row.group.name} ${row.group.standing}` : "", row.isolated ? "isolated" : ""].filter(Boolean).join(" \xB7 ");
656
926
  }
657
927
  function stateWord(row) {
658
928
  if (row.state === "waiting")
659
929
  return "waiting on you";
930
+ if (row.state === "stalled")
931
+ return "stalled";
932
+ if (row.state === "idle" && row.stopped)
933
+ return "stopped";
660
934
  if (row.state === "idle" && (row.outcome === "failed" || row.outcome === "interrupted"))
661
935
  return row.outcome;
662
936
  return row.state;
663
937
  }
664
- function reportWords(report) {
938
+ function reportWords(report, stopped) {
665
939
  if (!report)
666
940
  return "";
667
941
  if (report.state === "owed")
668
942
  return "owes a report";
669
943
  if (report.state === "ended")
670
- return "ended without a report";
944
+ return stopped ? "" : "ended without a report";
671
945
  return ["reported", report.status].filter(Boolean).join(" ");
672
946
  }
673
947
  var counted = (count, noun) => `${count} ${noun}${count === 1 ? "" : "s"}`;
674
948
  function activityLine(row, now) {
675
949
  return [`ran ${duration((row.idle ?? now) - row.created)}`, row.tool ? `last ${row.tool.name} ${duration(now - row.tool.at)} ago` : "no tool call yet", row.cost !== undefined ? `$${row.cost.toFixed(2)}` : ""].filter(Boolean).join(" \xB7 ");
676
950
  }
951
+ function stalledLine(since, now) {
952
+ return `a message waits in its inbox since ${duration(now - since)}, woken again in vain`;
953
+ }
677
954
  function detailLines(row, rows, now) {
678
955
  const started = parts(rows, row.sessionID);
679
956
  if (row.waits.length)
680
957
  return [...started.started ? [bar(started)] : [], ...row.waits.map(waitLine)];
958
+ if (row.stalled !== undefined)
959
+ return [...started.started ? [bar(started)] : [], stalledLine(row.stalled, now)];
681
960
  if (started.started)
682
961
  return [bar(started)];
683
962
  return row.state === "gone" ? [] : [activityLine(row, now)];
@@ -690,8 +969,10 @@ function courierOf(ctx) {
690
969
  location
691
970
  };
692
971
  }
972
+ var REREAD_MS = 15000;
693
973
  function changes(ctx, types, settle) {
694
974
  const [changed, setChanged] = createSignal(0);
975
+ const reread = setInterval(() => setChanged((count) => count + 1), REREAD_MS);
695
976
  let settling;
696
977
  const unlisten = ctx.data.listen(({
697
978
  details
@@ -705,6 +986,7 @@ function changes(ctx, types, settle) {
705
986
  });
706
987
  return [changed, () => {
707
988
  clearTimeout(settling);
989
+ clearInterval(reread);
708
990
  unlisten();
709
991
  }];
710
992
  }
@@ -733,34 +1015,44 @@ function addTreeStatus(ctx, onWaiting) {
733
1015
  location: location(sessionID)
734
1016
  }).then((summary) => ({
735
1017
  sessionID,
736
- line: summaryLine(summary),
1018
+ summary,
737
1019
  waiting: summary.waiting
738
1020
  }), () => ({
739
1021
  sessionID,
740
- line: "",
1022
+ summary: undefined,
741
1023
  waiting: 0
742
1024
  })));
743
- const shown2 = () => {
1025
+ const shown = () => {
744
1026
  const latest = read.state === "ready" || read.state === "refreshing" ? read.latest : undefined;
745
1027
  return latest && latest.sessionID === input.sessionID ? latest : undefined;
746
1028
  };
747
- const line = () => shown2()?.line ?? "";
1029
+ const [width, setWidth] = createSignal(0);
1030
+ const present = () => shown()?.summary ? summaryLine(shown().summary) : "";
1031
+ const line = () => width() && shown()?.summary ? summaryLine(shown().summary, width() - 1) : "";
748
1032
  const click = () => {
749
- const latest = shown2();
1033
+ const latest = shown();
750
1034
  if (latest?.waiting)
751
1035
  onWaiting?.(latest.sessionID);
752
1036
  };
753
1037
  return _$createComponent(Show, {
754
1038
  get when() {
755
- return line();
1039
+ return present();
756
1040
  },
757
1041
  get children() {
758
- var _el$ = _$createElement("text"), _el$2 = _$createTextNode(` `);
1042
+ var _el$ = _$createElement("box"), _el$2 = _$createElement("text"), _el$3 = _$createTextNode(` `);
759
1043
  _$insertNode(_el$, _el$2);
760
- _$setProp(_el$, "wrapMode", "none");
761
- _$setProp(_el$, "onMouseUp", click);
762
- _$insert(_el$, line, null);
763
- _$effect((_$p) => _$setProp(_el$, "fg", ctx.theme.text.muted, _$p));
1044
+ _$setProp(_el$, "flexGrow", 1);
1045
+ _$setProp(_el$, "flexShrink", 1);
1046
+ _$setProp(_el$, "minWidth", 0);
1047
+ _$setProp(_el$, "onSizeChange", function() {
1048
+ if (this.width > 0)
1049
+ setWidth(this.width);
1050
+ });
1051
+ _$insertNode(_el$2, _el$3);
1052
+ _$setProp(_el$2, "wrapMode", "none");
1053
+ _$setProp(_el$2, "onMouseUp", click);
1054
+ _$insert(_el$2, line, null);
1055
+ _$effect((_$p) => _$setProp(_el$2, "fg", ctx.theme.text.muted, _$p));
764
1056
  return _el$;
765
1057
  }
766
1058
  });
@@ -815,7 +1107,52 @@ async function confirmStop(ctx, courier, location, by, target) {
815
1107
  }
816
1108
  return true;
817
1109
  }
1110
+ function forgottenLine(title, result) {
1111
+ const name = `"${oneLine(title)}"`;
1112
+ if (result.outcome === "kept")
1113
+ return `Kept ${name} and its worktree: ${result.reason}`;
1114
+ if (result.outcome === "removed")
1115
+ return `Forgot ${name} and removed its worktree`;
1116
+ return result.outcome === "gone" ? `Forgot ${name}; its worktree was already gone` : `Forgot ${name}`;
1117
+ }
1118
+ async function confirmForget(ctx, courier, location, by, target, done) {
1119
+ const what = target.isolated ? "Its worktree is removed unless it has uncommitted changes or commits found nowhere else." : "It leaves this tree and courier_children.";
1120
+ const confirmed = await ctx.ui.dialog.confirm({
1121
+ title: "Forget session",
1122
+ message: `Forget "${oneLine(target.title)}" (${target.sessionID})? ${what}`,
1123
+ label: {
1124
+ confirm: "Forget",
1125
+ cancel: "Keep"
1126
+ }
1127
+ });
1128
+ if (!confirmed)
1129
+ return false;
1130
+ try {
1131
+ const result = await courier.cleanup({
1132
+ sessionID: target.sessionID,
1133
+ by
1134
+ }, {
1135
+ location: location(by)
1136
+ });
1137
+ ctx.ui.toast.show({
1138
+ title: "courier",
1139
+ message: forgottenLine(target.title, result),
1140
+ variant: result.outcome === "kept" ? "warning" : "success"
1141
+ });
1142
+ if (result.outcome !== "kept")
1143
+ done();
1144
+ } catch (error) {
1145
+ ctx.ui.toast.show({
1146
+ title: "courier forget",
1147
+ message: reason(error),
1148
+ variant: "error"
1149
+ });
1150
+ }
1151
+ return true;
1152
+ }
818
1153
  var FOCUS_PROMPT = "pane.focus.left";
1154
+ var SPLIT_WIDTH = 80;
1155
+ var oneLine = (title) => title.replace(/[\r\n]+/g, " ");
819
1156
  function TreePanel(props) {
820
1157
  const {
821
1158
  ctx,
@@ -855,10 +1192,18 @@ function TreePanel(props) {
855
1192
  tree
856
1193
  });
857
1194
  });
858
- createEffect(() => view.note({
859
- presentation: input.presentation,
860
- focused: input.focused
861
- }));
1195
+ createEffect(() => {
1196
+ const {
1197
+ presentation,
1198
+ focused
1199
+ } = input;
1200
+ view.note(ctx.renderer.width > SPLIT_WIDTH ? {
1201
+ presentation,
1202
+ focused
1203
+ } : {
1204
+ focused
1205
+ });
1206
+ });
862
1207
  onMount(() => {
863
1208
  if (!view.refocus)
864
1209
  return;
@@ -868,23 +1213,39 @@ function TreePanel(props) {
868
1213
  const [now, setNow] = createSignal(Date.now());
869
1214
  const tick = setInterval(() => setNow(Date.now()), TICK_MS);
870
1215
  onCleanup(() => clearInterval(tick));
871
- const [selected, setSelected] = createSignal(0);
1216
+ const [selectedID, setSelectedID] = createSignal();
872
1217
  let placed = false;
873
1218
  createEffect(() => {
874
- const index = placed ? -1 : rows().findIndex((row) => row.sessionID === input.sessionID);
875
- if (index < 0)
1219
+ const found = placed ? undefined : rows().find((row) => row.sessionID === input.sessionID);
1220
+ if (!found)
876
1221
  return;
877
1222
  placed = true;
878
- setSelected(index);
1223
+ setSelectedID(found.sessionID);
879
1224
  });
880
- const current = () => rows()[Math.min(selected(), rows().length - 1)];
1225
+ let at = 0;
1226
+ const selected = createMemo(() => {
1227
+ const id = selectedID();
1228
+ const found = rows().findIndex((row) => row.sessionID === id);
1229
+ const index = found >= 0 ? found : Math.min(at, rows().length - 1);
1230
+ at = Math.max(index, 0);
1231
+ return index;
1232
+ });
1233
+ createEffect(() => {
1234
+ const row = rows()[selected()];
1235
+ if (row && row.sessionID !== selectedID())
1236
+ setSelectedID(row.sessionID);
1237
+ });
1238
+ const current = () => rows()[selected()];
881
1239
  let scroll;
882
1240
  createEffect(() => {
883
- const index = Math.min(selected(), rows().length - 1);
884
- if (index >= 0)
885
- scroll?.scrollChildIntoView(`courier-row-${index}`);
1241
+ if (selected() >= 0)
1242
+ scroll?.scrollChildIntoView(`courier-row-${selected()}`);
886
1243
  });
887
- const move = (by) => setSelected((index) => Math.max(0, Math.min(rows().length - 1, Math.min(index, rows().length - 1) + by)));
1244
+ const move = (by) => {
1245
+ const next = rows()[Math.max(0, Math.min(rows().length - 1, selected() + by))];
1246
+ if (next)
1247
+ setSelectedID(next.sessionID);
1248
+ };
888
1249
  const open = () => {
889
1250
  const row = current();
890
1251
  if (row)
@@ -902,6 +1263,22 @@ function TreePanel(props) {
902
1263
  under: reach(rows(), row.sessionID) - 1
903
1264
  });
904
1265
  };
1266
+ const forgetSelected = () => {
1267
+ const row = current();
1268
+ if (!row)
1269
+ return;
1270
+ if (row.state !== "gone")
1271
+ return ctx.ui.toast.show({
1272
+ title: "courier",
1273
+ message: `x forgets only a gone session; "${oneLine(row.title)}" is ${row.state}.`,
1274
+ variant: "warning"
1275
+ });
1276
+ confirmForget(ctx, courier, location, view.root, {
1277
+ sessionID: row.sessionID,
1278
+ title: row.title,
1279
+ isolated: row.isolated
1280
+ }, () => void refetch());
1281
+ };
905
1282
  ctx.keymap.layer(() => ({
906
1283
  priority: 10,
907
1284
  enabled: () => input.focused,
@@ -935,6 +1312,11 @@ function TreePanel(props) {
935
1312
  title: "Stop session and those under it",
936
1313
  group: "Courier",
937
1314
  run: stopSelected
1315
+ }, {
1316
+ bind: "x",
1317
+ title: "Forget gone session",
1318
+ group: "Courier",
1319
+ run: forgetSelected
938
1320
  }, {
939
1321
  bind: "r",
940
1322
  title: "Refresh",
@@ -954,52 +1336,53 @@ function TreePanel(props) {
954
1336
  }));
955
1337
  const root = () => parts(rows(), view.root);
956
1338
  const rowColor = (row) => {
1339
+ if (row.state === "stalled")
1340
+ return theme.text.feedback.error.base;
957
1341
  if (row.state === "waiting")
958
1342
  return theme.text.feedback.warning.base;
959
1343
  return row.state === "gone" ? theme.text.muted : theme.text.base;
960
1344
  };
961
1345
  return (() => {
962
- var _el$3 = _$createElement("box"), _el$4 = _$createElement("box"), _el$5 = _$createElement("text"), _el$13 = _$createElement("scrollbox"), _el$14 = _$createElement("text");
963
- _$insertNode(_el$3, _el$4);
964
- _$insertNode(_el$3, _el$13);
965
- _$insertNode(_el$3, _el$14);
966
- _$setProp(_el$3, "flexDirection", "column");
967
- _$setProp(_el$3, "flexGrow", 1);
968
- _$setProp(_el$3, "paddingLeft", 1);
969
- _$setProp(_el$3, "paddingRight", 1);
1346
+ var _el$4 = _$createElement("box"), _el$5 = _$createElement("box"), _el$6 = _$createElement("text"), _el$15 = _$createElement("scrollbox"), _el$16 = _$createElement("text");
970
1347
  _$insertNode(_el$4, _el$5);
971
- _$setProp(_el$4, "flexDirection", "row");
972
- _$setProp(_el$4, "flexShrink", 0);
973
- _$setProp(_el$4, "gap", 2);
974
- _$insertNode(_el$5, _$createTextNode(`courier tree`));
975
- _$setProp(_el$5, "wrapMode", "none");
976
- _$insert(_el$4, _$createComponent(Show, {
1348
+ _$insertNode(_el$4, _el$15);
1349
+ _$insertNode(_el$4, _el$16);
1350
+ _$setProp(_el$4, "flexDirection", "column");
1351
+ _$setProp(_el$4, "flexGrow", 1);
1352
+ _$setProp(_el$4, "paddingLeft", 1);
1353
+ _$setProp(_el$4, "paddingRight", 1);
1354
+ _$insertNode(_el$5, _el$6);
1355
+ _$setProp(_el$5, "flexDirection", "row");
1356
+ _$setProp(_el$5, "flexShrink", 0);
1357
+ _$setProp(_el$5, "gap", 2);
1358
+ _$insertNode(_el$6, _$createTextNode(`courier tree`));
1359
+ _$setProp(_el$6, "wrapMode", "none");
1360
+ _$insert(_el$5, _$createComponent(Show, {
977
1361
  get when() {
978
1362
  return root().started;
979
1363
  },
980
1364
  get children() {
981
- var _el$7 = _$createElement("text");
982
- _$setProp(_el$7, "wrapMode", "none");
983
- _$insert(_el$7, () => bar(root()));
984
- _$effect((_$p) => _$setProp(_el$7, "fg", theme.text.muted, _$p));
985
- return _el$7;
1365
+ var _el$8 = _$createElement("text");
1366
+ _$setProp(_el$8, "wrapMode", "none");
1367
+ _$insert(_el$8, () => bar(root()));
1368
+ _$effect((_$p) => _$setProp(_el$8, "fg", theme.text.muted, _$p));
1369
+ return _el$8;
986
1370
  }
987
1371
  }), null);
988
- _$insert(_el$4, _$createComponent(Show, {
1372
+ _$insert(_el$5, _$createComponent(Show, {
989
1373
  get when() {
990
1374
  return latest()?.tree?.truncated;
991
1375
  },
992
1376
  get children() {
993
- var _el$8 = _$createElement("text"), _el$9 = _$createTextNode(`first `), _el$0 = _$createTextNode(` shown`);
994
- _$insertNode(_el$8, _el$9);
995
- _$insertNode(_el$8, _el$0);
996
- _$setProp(_el$8, "wrapMode", "none");
997
- _$insert(_el$8, () => latest()?.tree?.truncated, _el$0);
998
- _$effect((_$p) => _$setProp(_el$8, "fg", theme.text.feedback.warning.base, _$p));
999
- return _el$8;
1377
+ var _el$9 = _$createElement("text"), _el$0 = _$createTextNode(` older left out`);
1378
+ _$insertNode(_el$9, _el$0);
1379
+ _$setProp(_el$9, "wrapMode", "none");
1380
+ _$insert(_el$9, () => latest()?.tree?.truncated, _el$0);
1381
+ _$effect((_$p) => _$setProp(_el$9, "fg", theme.text.feedback.warning.base, _$p));
1382
+ return _el$9;
1000
1383
  }
1001
1384
  }), null);
1002
- _$insert(_el$3, _$createComponent(Show, {
1385
+ _$insert(_el$4, _$createComponent(Show, {
1003
1386
  get when() {
1004
1387
  return latest()?.error;
1005
1388
  },
@@ -1010,62 +1393,79 @@ function TreePanel(props) {
1010
1393
  _$effect((_$p) => _$setProp(_el$1, "fg", theme.text.feedback.error.base, _$p));
1011
1394
  return _el$1;
1012
1395
  }
1013
- }), _el$13);
1014
- _$insert(_el$3, _$createComponent(Show, {
1396
+ }), _el$15);
1397
+ _$insert(_el$4, _$createComponent(Show, {
1015
1398
  get when() {
1016
- return _$memo(() => !!latest()?.tree)() && !rows().length;
1399
+ return latest()?.tree?.stalled;
1017
1400
  },
1018
1401
  get children() {
1019
- var _el$11 = _$createElement("text");
1020
- _$insertNode(_el$11, _$createTextNode(`No session started with courier_spawn under this one.`));
1021
- _$effect((_$p) => _$setProp(_el$11, "fg", theme.text.muted, _$p));
1402
+ var _el$11 = _$createElement("text"), _el$12 = _$createTextNode(`This session is stalled: `);
1403
+ _$insertNode(_el$11, _el$12);
1404
+ _$insert(_el$11, () => stalledLine(latest().tree.stalled, now()), null);
1405
+ _$effect((_$p) => _$setProp(_el$11, "fg", theme.text.feedback.error.base, _$p));
1022
1406
  return _el$11;
1023
1407
  }
1024
- }), _el$13);
1025
- _$use((value) => scroll = value, _el$13);
1026
- _$setProp(_el$13, "flexGrow", 1);
1027
- _$insert(_el$13, _$createComponent(For, {
1408
+ }), _el$15);
1409
+ _$insert(_el$4, _$createComponent(Show, {
1410
+ get when() {
1411
+ return _$memo(() => !!latest()?.tree)() && !rows().length;
1412
+ },
1413
+ get children() {
1414
+ var _el$13 = _$createElement("text");
1415
+ _$insertNode(_el$13, _$createTextNode(`No session started with courier_spawn under this one.`));
1416
+ _$effect((_$p) => _$setProp(_el$13, "fg", theme.text.muted, _$p));
1417
+ return _el$13;
1418
+ }
1419
+ }), _el$15);
1420
+ _$use((value) => scroll = value, _el$15);
1421
+ _$setProp(_el$15, "flexGrow", 1);
1422
+ _$insert(_el$15, _$createComponent(For, {
1028
1423
  get each() {
1029
1424
  return rows();
1030
1425
  },
1031
1426
  children: (row, index) => {
1032
- const chosen = () => index() === Math.min(selected(), rows().length - 1);
1427
+ const chosen = () => index() === selected();
1033
1428
  const indent = " ".repeat(row.depth - 1);
1034
1429
  return (() => {
1035
- var _el$16 = _$createElement("box"), _el$17 = _$createElement("text"), _el$18 = _$createTextNode(` `), _el$19 = _$createElement("span");
1036
- _$insertNode(_el$16, _el$17);
1037
- _$setProp(_el$16, "flexDirection", "column");
1038
- _$setProp(_el$16, "flexShrink", 0);
1039
- _$insertNode(_el$17, _el$18);
1040
- _$insertNode(_el$17, _el$19);
1041
- _$setProp(_el$17, "wrapMode", "none");
1042
- _$insert(_el$17, indent, _el$18);
1043
- _$insert(_el$17, () => chosen() ? "\u25B8 " : " ", _el$18);
1044
- _$insert(_el$17, () => row.title, _el$18);
1045
- _$insert(_el$17, () => row.sessionID === input.sessionID ? " (on screen)" : "", _el$18);
1046
- _$insert(_el$19, () => statusLine(row));
1047
- _$insert(_el$16, _$createComponent(For, {
1430
+ var _el$18 = _$createElement("box"), _el$19 = _$createElement("box"), _el$20 = _$createElement("text"), _el$21 = _$createElement("text"), _el$22 = _$createTextNode(` `);
1431
+ _$insertNode(_el$18, _el$19);
1432
+ _$setProp(_el$18, "flexDirection", "column");
1433
+ _$setProp(_el$18, "flexShrink", 0);
1434
+ _$insertNode(_el$19, _el$20);
1435
+ _$insertNode(_el$19, _el$21);
1436
+ _$setProp(_el$19, "flexDirection", "row");
1437
+ _$setProp(_el$19, "flexShrink", 0);
1438
+ _$setProp(_el$20, "wrapMode", "none");
1439
+ _$setProp(_el$20, "flexShrink", 1);
1440
+ _$setProp(_el$20, "minWidth", 0);
1441
+ _$insert(_el$20, indent, null);
1442
+ _$insert(_el$20, () => chosen() ? "\u25B8 " : " ", null);
1443
+ _$insert(_el$20, () => oneLine(row.title), null);
1444
+ _$insert(_el$20, () => row.sessionID === input.sessionID ? " (on screen)" : "", null);
1445
+ _$insertNode(_el$21, _el$22);
1446
+ _$setProp(_el$21, "wrapMode", "none");
1447
+ _$setProp(_el$21, "flexShrink", 0);
1448
+ _$insert(_el$21, () => statusLine(row), null);
1449
+ _$insert(_el$18, _$createComponent(For, {
1048
1450
  get each() {
1049
1451
  return detailLines(row, rows(), now());
1050
1452
  },
1051
1453
  children: (line) => (() => {
1052
- var _el$20 = _$createElement("text"), _el$21 = _$createTextNode(` `);
1053
- _$insertNode(_el$20, _el$21);
1054
- _$setProp(_el$20, "wrapMode", "none");
1055
- _$insert(_el$20, indent, _el$21);
1056
- _$insert(_el$20, line, null);
1057
- _$effect((_$p) => _$setProp(_el$20, "fg", theme.text.muted, _$p));
1058
- return _el$20;
1454
+ var _el$23 = _$createElement("text"), _el$24 = _$createTextNode(` `);
1455
+ _$insertNode(_el$23, _el$24);
1456
+ _$setProp(_el$23, "wrapMode", "none");
1457
+ _$insert(_el$23, indent, _el$24);
1458
+ _$insert(_el$23, line, null);
1459
+ _$effect((_$p) => _$setProp(_el$23, "fg", theme.text.muted, _$p));
1460
+ return _el$23;
1059
1461
  })()
1060
1462
  }), null);
1061
1463
  _$effect((_p$) => {
1062
- var _v$3 = `courier-row-${index()}`, _v$4 = chosen() && input.focused ? theme.background.raised.high : undefined, _v$5 = rowColor(row), _v$6 = {
1063
- fg: theme.text.muted
1064
- };
1065
- _v$3 !== _p$.e && (_p$.e = _$setProp(_el$16, "id", _v$3, _p$.e));
1066
- _v$4 !== _p$.t && (_p$.t = _$setProp(_el$16, "backgroundColor", _v$4, _p$.t));
1067
- _v$5 !== _p$.a && (_p$.a = _$setProp(_el$17, "fg", _v$5, _p$.a));
1068
- _v$6 !== _p$.o && (_p$.o = _$setProp(_el$19, "style", _v$6, _p$.o));
1464
+ var _v$3 = `courier-row-${index()}`, _v$4 = chosen() && input.focused ? theme.background.raised.high : undefined, _v$5 = rowColor(row), _v$6 = theme.text.muted;
1465
+ _v$3 !== _p$.e && (_p$.e = _$setProp(_el$18, "id", _v$3, _p$.e));
1466
+ _v$4 !== _p$.t && (_p$.t = _$setProp(_el$18, "backgroundColor", _v$4, _p$.t));
1467
+ _v$5 !== _p$.a && (_p$.a = _$setProp(_el$20, "fg", _v$5, _p$.a));
1468
+ _v$6 !== _p$.o && (_p$.o = _$setProp(_el$21, "fg", _v$6, _p$.o));
1069
1469
  return _p$;
1070
1470
  }, {
1071
1471
  e: undefined,
@@ -1073,23 +1473,23 @@ function TreePanel(props) {
1073
1473
  a: undefined,
1074
1474
  o: undefined
1075
1475
  });
1076
- return _el$16;
1476
+ return _el$18;
1077
1477
  })();
1078
1478
  }
1079
1479
  }));
1080
- _$insertNode(_el$14, _$createTextNode(`\u2191\u2193 select \xB7 enter open \xB7 s stop \xB7 r refresh \xB7 f fullscreen \xB7 q close`));
1081
- _$setProp(_el$14, "wrapMode", "none");
1082
- _$setProp(_el$14, "flexShrink", 0);
1480
+ _$insertNode(_el$16, _$createTextNode(`\u2191\u2193 select \xB7 enter open \xB7 s stop \xB7 x forget \xB7 r refresh \xB7 f fullscreen \xB7 q close`));
1481
+ _$setProp(_el$16, "wrapMode", "none");
1482
+ _$setProp(_el$16, "flexShrink", 0);
1083
1483
  _$effect((_p$) => {
1084
1484
  var _v$ = theme.text.base, _v$2 = theme.text.muted;
1085
- _v$ !== _p$.e && (_p$.e = _$setProp(_el$5, "fg", _v$, _p$.e));
1086
- _v$2 !== _p$.t && (_p$.t = _$setProp(_el$14, "fg", _v$2, _p$.t));
1485
+ _v$ !== _p$.e && (_p$.e = _$setProp(_el$6, "fg", _v$, _p$.e));
1486
+ _v$2 !== _p$.t && (_p$.t = _$setProp(_el$16, "fg", _v$2, _p$.t));
1087
1487
  return _p$;
1088
1488
  }, {
1089
1489
  e: undefined,
1090
1490
  t: undefined
1091
1491
  });
1092
- return _el$3;
1492
+ return _el$4;
1093
1493
  })();
1094
1494
  }
1095
1495
  var USAGE = "/courier tree opens the session tree; /courier stop [session] stops a session and those under it";
@@ -1187,11 +1587,11 @@ function addTreePanel(ctx) {
1187
1587
  createEffect(() => {
1188
1588
  const route = ctx.ui.router.current();
1189
1589
  const sessionID = route.type === "session" ? route.sessionID : undefined;
1190
- const shown2 = ctx.ui.panel.current();
1590
+ const shown = ctx.ui.panel.current();
1191
1591
  untrack(() => {
1192
- if (!following || shown2 && shown2.sessionID === sessionID || !following.at && following.to === sessionID)
1592
+ if (!following || shown && shown.sessionID === sessionID || !following.at && following.to === sessionID)
1193
1593
  return;
1194
- if (!shown2 && sessionID && following.at === sessionID)
1594
+ if (!shown && sessionID && following.at === sessionID)
1195
1595
  following = undefined;
1196
1596
  else if (!sessionID)
1197
1597
  hide(following);
@@ -1237,11 +1637,29 @@ function addTreePanel(ctx) {
1237
1637
  message: "Open a session first",
1238
1638
  variant: "warning"
1239
1639
  });
1240
- const rows = await courier.tree({
1640
+ const read = await courier.tree({
1241
1641
  sessionID: route.sessionID
1242
1642
  }, {
1243
1643
  location: location(route.sessionID)
1244
- }).then((tree) => tree.rows, () => []);
1644
+ }).then((tree) => ({
1645
+ rows: tree.rows
1646
+ }), (error) => ({
1647
+ rows: [],
1648
+ error
1649
+ }));
1650
+ const rows = read.rows;
1651
+ if (!target && "error" in read)
1652
+ return ctx.ui.toast.show({
1653
+ title: "courier stop",
1654
+ message: reason(read.error),
1655
+ variant: "error"
1656
+ });
1657
+ if (!target && !rows.length)
1658
+ return ctx.ui.toast.show({
1659
+ title: "courier stop",
1660
+ message: "No session started with courier_spawn under this one.",
1661
+ variant: "warning"
1662
+ });
1245
1663
  const sessionID = target || await ctx.ui.dialog.select({
1246
1664
  title: "Stop which session?",
1247
1665
  options: rows.map((row2) => ({
@@ -1314,6 +1732,8 @@ function addTreeAttention(ctx) {
1314
1732
  const from = typeof metadata.from === "string" ? metadata.from : "";
1315
1733
  if (!group && !status)
1316
1734
  return;
1735
+ if (!group && ctx.data.session.get(from)?.parentID !== undefined)
1736
+ return;
1317
1737
  notified.add(inboxID);
1318
1738
  if (notified.size > NOTIFIED_MAX)
1319
1739
  notified.delete(notified.values().next().value);
@@ -1345,7 +1765,7 @@ function addTreeAttention(ctx) {
1345
1765
  var tui_default = Plugin.define({
1346
1766
  id: "courier.tui",
1347
1767
  setup: (ctx) => {
1348
- const inbox = addAsksInbox(ctx);
1768
+ const inbox = addAsksInbox(ctx, CHANGES);
1349
1769
  const cleanups = [inbox.cleanup, addTreeStatus(ctx, (sessionID) => void inbox.open(sessionID)), addTreePanel(ctx), addTreeAttention(ctx)];
1350
1770
  return () => {
1351
1771
  for (const cleanup of cleanups.toReversed())