@north-light/crouter 0.3.213 → 0.3.215
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/dist/api/dto/inbox.d.ts +19 -0
- package/dist/api/dto/nodes.d.ts +2 -1
- package/dist/api/dto/profiles.d.ts +13 -1
- package/dist/builtin-memory/00-runtime-base.md +12 -11
- package/dist/builtin-memory/01-spine/00-has-manager.md +1 -11
- package/dist/builtin-memory/02-lifecycle/00-terminal.md +4 -10
- package/dist/builtin-memory/04-orchestration-kernel.md +9 -35
- package/dist/builtin-memory/05-kinds/design/00-base.md +2 -2
- package/dist/builtin-memory/05-kinds/design/01-orchestrator.md +1 -1
- package/dist/builtin-memory/05-kinds/developer/00-base.md +2 -2
- package/dist/builtin-memory/05-kinds/explore/00-base.md +1 -1
- package/dist/builtin-memory/05-kinds/general/00-base.md +2 -0
- package/dist/builtin-memory/05-kinds/plan/00-base.md +1 -1
- package/dist/builtin-memory/05-kinds/plan/reviewers/security.md +1 -1
- package/dist/builtin-memory/05-kinds/review/00-base.md +1 -3
- package/dist/builtin-memory/05-kinds/review/01-orchestrator.md +1 -1
- package/dist/builtin-memory/05-kinds/review/security-findings.md +12 -0
- package/dist/builtin-memory/05-kinds/spec/00-base.md +1 -1
- package/dist/clients/attach/__tests__/context-message.test.js +33 -5
- package/dist/clients/attach/chrome/canvas-panels.d.ts +0 -4
- package/dist/clients/attach/chrome/canvas-panels.js +5 -7
- package/dist/clients/attach/chrome/inbox-strip.d.ts +31 -0
- package/dist/clients/attach/chrome/inbox-strip.js +187 -0
- package/dist/clients/attach/chrome/roster.d.ts +6 -2
- package/dist/clients/attach/chrome/roster.js +13 -52
- package/dist/clients/attach/chrome/ticket-panel.d.ts +29 -0
- package/dist/clients/attach/chrome/ticket-panel.js +236 -0
- package/dist/clients/attach/render/card-presentation.d.ts +8 -3
- package/dist/clients/attach/render/card-presentation.js +48 -10
- package/dist/clients/attach/render/context-message.d.ts +5 -0
- package/dist/clients/attach/render/context-message.js +20 -14
- package/dist/clients/attach/session/context.d.ts +3 -0
- package/dist/clients/attach/session/frame.d.ts +4 -0
- package/dist/clients/attach/session/frame.js +8 -3
- package/dist/clients/attach/session/keys.d.ts +7 -0
- package/dist/clients/attach/session/keys.js +7 -0
- package/dist/clients/attach/session/layout.js +6 -3
- package/dist/clients/attach/session/pane-focus.d.ts +8 -0
- package/dist/clients/attach/session/pane-focus.js +42 -0
- package/dist/clients/attach/viewer.js +794 -790
- package/dist/clients/inbox/controller.d.ts +9 -0
- package/dist/clients/inbox/controller.js +57 -2
- package/dist/clients/inbox/surface.d.ts +2 -0
- package/dist/clients/inbox/surface.js +18 -3
- package/dist/clients/inbox/tui.d.ts +2 -0
- package/dist/clients/inbox/tui.js +11 -1
- package/dist/commands/node/create.js +3 -3
- package/dist/commands/profile/kind.d.ts +2 -0
- package/dist/commands/profile/kind.js +51 -0
- package/dist/commands/profile/list.js +5 -1
- package/dist/commands/profile/meta.d.ts +4 -0
- package/dist/commands/profile/meta.js +67 -0
- package/dist/commands/profile/new.js +33 -3
- package/dist/commands/profile/pause.d.ts +2 -0
- package/dist/commands/profile/pause.js +60 -0
- package/dist/commands/profile/show.js +9 -1
- package/dist/commands/profile.js +5 -8
- package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +40 -0
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +1 -1
- package/dist/core/__tests__/dead-node-policy-table.test.js +13 -0
- package/dist/core/__tests__/parse-argv-stdin-secret.test.js +28 -0
- package/dist/core/__tests__/seam/dormancy-release.test.js +7 -0
- package/dist/core/command.js +23 -23
- package/dist/core/feed/inbox.js +1 -6
- package/dist/core/help.d.ts +1 -1
- package/dist/core/human/component-docs.js +3 -2
- package/dist/core/human/page-schema.d.ts +28 -0
- package/dist/core/human/page-schema.js +35 -2
- package/dist/core/human/scan.js +7 -1
- package/dist/core/human/types.d.ts +4 -0
- package/dist/core/keybindings/attach-control.d.ts +3 -0
- package/dist/core/keybindings/attach-control.js +1 -0
- package/dist/core/keybindings/catalog.js +1 -0
- package/dist/core/profiles/deletion-reservation.d.ts +7 -3
- package/dist/core/profiles/deletion-reservation.js +9 -5
- package/dist/core/profiles/manifest.d.ts +27 -1
- package/dist/core/profiles/manifest.js +122 -4
- package/dist/core/runtime/boot-root.js +3 -3
- package/dist/core/runtime/broker/inbox.js +1 -5
- package/dist/core/runtime/close.js +10 -5
- package/dist/core/runtime/revive.js +2 -0
- package/dist/core/runtime/spawn-env.d.ts +9 -1
- package/dist/core/runtime/spawn-env.js +17 -1
- package/dist/core/substrate/on-read.js +16 -0
- package/dist/core/termrender/termrender.d.ts +7 -2
- package/dist/core/termrender/termrender.js +11 -6
- package/dist/core/termrender/version.d.ts +1 -1
- package/dist/core/termrender/version.js +1 -1
- package/dist/daemon/api/__tests__/profile-launch-gates.test.d.ts +1 -0
- package/dist/daemon/api/__tests__/profile-launch-gates.test.js +111 -0
- package/dist/daemon/api/handlers/inbox.js +31 -41
- package/dist/daemon/api/handlers/messages.js +9 -2
- package/dist/daemon/api/handlers/nodes.d.ts +2 -0
- package/dist/daemon/api/handlers/nodes.js +15 -8
- package/dist/daemon/api/handlers/profiles.js +7 -2
- package/dist/daemon/api/map.js +3 -0
- package/dist/daemon/fleet.d.ts +8 -4
- package/dist/daemon/fleet.js +37 -9
- package/dist/daemon/reconcilers/broker-supervision.js +15 -25
- package/dist/daemon/reconcilers/dormant-inbox.js +10 -10
- package/dist/daemon/reconcilers/live-obligation.d.ts +14 -1
- package/dist/daemon/reconcilers/live-obligation.js +27 -18
- package/dist/pi-extensions/canvas-inbox-watcher.js +15 -0
- package/dist/shared/__tests__/generated-context-grammar.test.js +4 -6
- package/dist/shared/generated-context.d.ts +0 -4
- package/dist/shared/generated-context.js +6 -9
- package/dist/types.d.ts +9 -0
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
- package/dist/builtin-memory/01-spine/01-no-manager.md +0 -11
- package/dist/builtin-memory/05-kinds/general/01-orchestrator.md +0 -8
- /package/dist/builtin-memory/05-kinds/advisor/{00-base.md → advice-contract.md} +0 -0
- /package/dist/builtin-memory/05-kinds/plan/reviewers/{00-base.md → lens-contract.md} +0 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// chrome/inbox-strip.ts — the pending-request strip pinned at the top of the
|
|
2
|
+
// pane, below the recap goal line.
|
|
3
|
+
//
|
|
4
|
+
// One row per pending human ticket raised by THIS node or any node in its
|
|
5
|
+
// subtree (self + descendants, convo children excluded), newest first. A
|
|
6
|
+
// dedicated binding moves the highlight up into it (roster-style — no real
|
|
7
|
+
// focus steal); ↑/↓ and j/k move, Enter opens the ticket's page inline in the
|
|
8
|
+
// cockpit (chrome/ticket-panel.ts), Esc / ↓ off the bottom / typing returns to
|
|
9
|
+
// the editor. Zero pending → zero rows, invisible.
|
|
10
|
+
//
|
|
11
|
+
// The strip owns the SUBTREE ticket scan and caches it: the viewer's inbox
|
|
12
|
+
// surface merges `pendingTickets()` into its own scan so an Enter-opened
|
|
13
|
+
// child ticket is present in the surface it seats.
|
|
14
|
+
import { Container, Text, decodeKittyPrintable, matchesKey } from '@earendil-works/pi-tui';
|
|
15
|
+
import { DynamicBorder } from '@earendil-works/pi-coding-agent';
|
|
16
|
+
import { scanInbox, scanPageHistory } from '../../../core/human/scan.js';
|
|
17
|
+
import { fillBar, fillWidth, navLabel, stripSgr, truncate, REVERSE, YELLOW, RESET } from '../../../core/canvas/nav-render.js';
|
|
18
|
+
/** Ticket rows shown at once; the window follows the selection. */
|
|
19
|
+
const MAX_ROWS = 3;
|
|
20
|
+
function ticketTime(t) {
|
|
21
|
+
return t.kind === 'page' ? t.emittedAt : t.blockedSince;
|
|
22
|
+
}
|
|
23
|
+
function ageLabel(iso) {
|
|
24
|
+
const ms = Date.now() - Date.parse(iso);
|
|
25
|
+
if (!Number.isFinite(ms) || ms < 0)
|
|
26
|
+
return 'now';
|
|
27
|
+
const minutes = Math.floor(ms / 60_000);
|
|
28
|
+
if (minutes < 1)
|
|
29
|
+
return 'now';
|
|
30
|
+
if (minutes < 60)
|
|
31
|
+
return `${minutes}m`;
|
|
32
|
+
const hours = Math.floor(minutes / 60);
|
|
33
|
+
if (hours < 24)
|
|
34
|
+
return `${hours}h`;
|
|
35
|
+
return `${Math.floor(hours / 24)}d`;
|
|
36
|
+
}
|
|
37
|
+
export function createInboxStrip(s, hooks) {
|
|
38
|
+
const { nodeId, remote, canvasSource, tui, pal } = s;
|
|
39
|
+
const component = new Container();
|
|
40
|
+
let tickets = [];
|
|
41
|
+
let nodes = new Map();
|
|
42
|
+
let selDir; // undefined ⇒ strip inactive (editor owns the cursor)
|
|
43
|
+
let top = 0; // first visible ticket row
|
|
44
|
+
const rowLine = (t) => {
|
|
45
|
+
const origin = t.source.nodeId;
|
|
46
|
+
const from = origin !== undefined && origin !== nodeId
|
|
47
|
+
? ` · ${navLabel(nodes.get(origin) ?? null, origin)}`
|
|
48
|
+
: '';
|
|
49
|
+
return truncate(` ${YELLOW}⚑${RESET} ${t.title}${pal.faint(` · ${ageLabel(ticketTime(t))}${from}`)}`);
|
|
50
|
+
};
|
|
51
|
+
const paint = () => {
|
|
52
|
+
hooks.syncCursor();
|
|
53
|
+
component.clear();
|
|
54
|
+
if (tickets.length > 0 && !hooks.suppressed()) {
|
|
55
|
+
const i = selDir === undefined ? -1 : tickets.findIndex((t) => t.dir === selDir);
|
|
56
|
+
if (i >= 0)
|
|
57
|
+
top = Math.min(Math.max(top, i - MAX_ROWS + 1), i);
|
|
58
|
+
top = Math.max(0, Math.min(top, Math.max(0, tickets.length - MAX_ROWS)));
|
|
59
|
+
for (const t of tickets.slice(top, top + MAX_ROWS)) {
|
|
60
|
+
const line = rowLine(t);
|
|
61
|
+
component.addChild(new Text(t.dir === selDir ? fillBar(stripSgr(line), fillWidth(), REVERSE) : line, 1, 0));
|
|
62
|
+
}
|
|
63
|
+
const hidden = tickets.length - Math.min(MAX_ROWS, tickets.length - top);
|
|
64
|
+
if (hidden > 0)
|
|
65
|
+
component.addChild(new Text(pal.faint(` +${hidden} more`), 1, 0));
|
|
66
|
+
component.addChild(new DynamicBorder(pal.border));
|
|
67
|
+
}
|
|
68
|
+
tui.requestRender();
|
|
69
|
+
};
|
|
70
|
+
/** Self + descendants through subscriptions, skipping convo (human-kind) children. */
|
|
71
|
+
const subtreeNodes = async () => {
|
|
72
|
+
const out = new Map();
|
|
73
|
+
const visit = async (id) => {
|
|
74
|
+
if (out.has(id))
|
|
75
|
+
return;
|
|
76
|
+
const meta = await canvasSource.getNode(id).catch(() => null);
|
|
77
|
+
if (id !== nodeId && meta?.kind === 'human')
|
|
78
|
+
return;
|
|
79
|
+
out.set(id, meta);
|
|
80
|
+
const refs = await canvasSource.subscriptionsOf(id).catch(() => []);
|
|
81
|
+
for (const ref of refs)
|
|
82
|
+
await visit(ref.node_id);
|
|
83
|
+
};
|
|
84
|
+
await visit(nodeId);
|
|
85
|
+
return out;
|
|
86
|
+
};
|
|
87
|
+
const refresh = async () => {
|
|
88
|
+
if (remote)
|
|
89
|
+
return; // a remote attach cannot read this canvas's ticket dirs
|
|
90
|
+
nodes = await subtreeNodes();
|
|
91
|
+
// Same pending merge the inbox surface does — inbox-projected tickets plus
|
|
92
|
+
// reply-bearing no-inbox pages — but for every subtree node, then scoped to
|
|
93
|
+
// tickets whose origin is in the subtree.
|
|
94
|
+
const merged = new Map();
|
|
95
|
+
for (const item of scanInbox())
|
|
96
|
+
merged.set(item.dir, item);
|
|
97
|
+
for (const id of nodes.keys()) {
|
|
98
|
+
for (const page of scanPageHistory(id)) {
|
|
99
|
+
if (page.state !== 'pending' || !page.awaitsResponse || page.inbox)
|
|
100
|
+
continue;
|
|
101
|
+
if (!merged.has(page.dir))
|
|
102
|
+
merged.set(page.dir, page);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
tickets = [...merged.values()]
|
|
106
|
+
.filter((t) => t.source.nodeId !== undefined && nodes.has(t.source.nodeId))
|
|
107
|
+
.sort((a, b) => ticketTime(b).localeCompare(ticketTime(a)) || a.id.localeCompare(b.id));
|
|
108
|
+
// The selected row may have vanished (answered, canceled) — drop back to
|
|
109
|
+
// the editor so no ghost highlight lingers.
|
|
110
|
+
if (selDir !== undefined && !tickets.some((t) => t.dir === selDir))
|
|
111
|
+
selDir = undefined;
|
|
112
|
+
paint();
|
|
113
|
+
};
|
|
114
|
+
const exit = () => {
|
|
115
|
+
if (selDir !== undefined) {
|
|
116
|
+
selDir = undefined;
|
|
117
|
+
paint();
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
const toggle = () => {
|
|
121
|
+
if (remote)
|
|
122
|
+
return;
|
|
123
|
+
if (selDir !== undefined) {
|
|
124
|
+
exit();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (tickets.length === 0) {
|
|
128
|
+
hooks.setNotice('No pending human requests in this subtree');
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
hooks.exitRoster();
|
|
132
|
+
selDir = tickets[0].dir;
|
|
133
|
+
top = 0;
|
|
134
|
+
paint();
|
|
135
|
+
};
|
|
136
|
+
const openSelected = () => {
|
|
137
|
+
const ticket = tickets.find((t) => t.dir === selDir);
|
|
138
|
+
if (ticket === undefined)
|
|
139
|
+
return;
|
|
140
|
+
exit();
|
|
141
|
+
hooks.openTicket(ticket);
|
|
142
|
+
};
|
|
143
|
+
const handleKey = (data) => {
|
|
144
|
+
if (selDir === undefined)
|
|
145
|
+
return false;
|
|
146
|
+
const i = tickets.findIndex((t) => t.dir === selDir);
|
|
147
|
+
// j/k are printable nav keys here, like the graph and browse lists — decode
|
|
148
|
+
// them so they move the highlight instead of falling to the typed-key exit.
|
|
149
|
+
const ch = decodeKittyPrintable(data) ?? (data.length === 1 ? data : undefined);
|
|
150
|
+
if (matchesKey(data, 'up') || ch === 'k') {
|
|
151
|
+
if (i > 0) {
|
|
152
|
+
selDir = tickets[i - 1].dir;
|
|
153
|
+
paint();
|
|
154
|
+
}
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
if (matchesKey(data, 'down') || ch === 'j') {
|
|
158
|
+
if (i >= 0 && i < tickets.length - 1) {
|
|
159
|
+
selDir = tickets[i + 1].dir;
|
|
160
|
+
paint();
|
|
161
|
+
}
|
|
162
|
+
else
|
|
163
|
+
exit(); // ↓ off the bottom returns to the editor
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
if (matchesKey(data, 'enter')) {
|
|
167
|
+
openSelected();
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
if (matchesKey(data, 'escape')) {
|
|
171
|
+
exit();
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
// Any other key (the user started typing) returns to the editor AND passes
|
|
175
|
+
// through, so the keystroke lands in the input.
|
|
176
|
+
exit();
|
|
177
|
+
return false;
|
|
178
|
+
};
|
|
179
|
+
return {
|
|
180
|
+
component,
|
|
181
|
+
refresh,
|
|
182
|
+
handleKey,
|
|
183
|
+
toggle,
|
|
184
|
+
active: () => selDir !== undefined,
|
|
185
|
+
pendingTickets: () => [...tickets],
|
|
186
|
+
};
|
|
187
|
+
}
|
|
@@ -19,6 +19,8 @@ export interface RosterHooks {
|
|
|
19
19
|
* the cron wait row routes to `schedule` and the bash row to `jobs`; the
|
|
20
20
|
* native Inspector owns details for both. */
|
|
21
21
|
openInspector: (section: 'overview' | 'schedule' | 'jobs') => void;
|
|
22
|
+
/** Recompute the editor's hollow-cursor state (session/pane-focus.ts). */
|
|
23
|
+
syncCursor: () => void;
|
|
22
24
|
}
|
|
23
25
|
export interface AttachRoster {
|
|
24
26
|
/** Rebuild the rows from the canvas source, reconcile the selection, repaint. */
|
|
@@ -26,7 +28,9 @@ export interface AttachRoster {
|
|
|
26
28
|
/** Roster key routing, called BEFORE the editor sees the key. Returns true
|
|
27
29
|
* when the roster consumed it (the editor must not see it). */
|
|
28
30
|
handleKey(data: string): boolean;
|
|
29
|
-
/**
|
|
30
|
-
|
|
31
|
+
/** True while the roster holds the highlight. */
|
|
32
|
+
active(): boolean;
|
|
33
|
+
/** Drop the highlight back to the editor (the inbox strip activating). */
|
|
34
|
+
exit(): void;
|
|
31
35
|
}
|
|
32
36
|
export declare function createRoster(s: RosterSession, hooks: RosterHooks): AttachRoster;
|
|
@@ -4,53 +4,31 @@
|
|
|
4
4
|
// editor's visual bottom drops the cursor into the list; within it ↑/↓ move the
|
|
5
5
|
// highlight, ↑ off the TOP row returns to the editor, ↓ CLAMPS at the bottom,
|
|
6
6
|
// Enter opens the row (SWAPS this pane in place onto a node, or opens the wait
|
|
7
|
-
// detail surface for a
|
|
8
|
-
//
|
|
9
|
-
//
|
|
7
|
+
// detail surface for a cron/bash/review row: a cron row the Inspector's
|
|
8
|
+
// schedule section, a bash row the Inspector's jobs section, a review row the
|
|
9
|
+
// review's companion viewer),
|
|
10
10
|
// Esc / any typed key returns to the editor. Disabled entirely for a REMOTE
|
|
11
11
|
// attach: a remote graph id has no local pane to swap into and no local
|
|
12
12
|
// jobs/crons to inspect.
|
|
13
13
|
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
// scrolling the list the editor is "away", so it must read exactly like an
|
|
22
|
-
// unfocused pane. The viewer feeds pane focus in through `setPaneFocused`;
|
|
23
|
-
// the roster half of that conjunction is private to this module.
|
|
14
|
+
// ONE EDITOR COUPLING IS OWNED HERE, deliberately and exclusively:
|
|
15
|
+
// `editor.onCursorDownAtBottom` — the boundary handoff. The editor owns native
|
|
16
|
+
// ↓ navigation and calls this only after both its visual cursor and its prompt
|
|
17
|
+
// history are exhausted, which makes the handoff a direct boundary transition
|
|
18
|
+
// rather than a global-listener replay. The hollow-cursor state lives in
|
|
19
|
+
// session/pane-focus.ts; the roster reports its held highlight through
|
|
20
|
+
// `active()` and pokes `hooks.syncCursor` on every repaint.
|
|
24
21
|
//
|
|
25
22
|
// Repainting a selection is purely local: canvas reads can traverse the whole
|
|
26
23
|
// graph, so `refresh()` rebuilds the cached rows asynchronously while arrow
|
|
27
24
|
// movement only reverses the selected cached row and renders immediately.
|
|
28
|
-
import { execFile
|
|
25
|
+
import { execFile } from 'node:child_process';
|
|
29
26
|
import { Text, decodeKittyPrintable, matchesKey } from '@earendil-works/pi-tui';
|
|
30
27
|
import { cliClient } from '../../../commands/api-client.js';
|
|
31
28
|
import { activeBackgroundBashJobs } from '../../../core/bash-jobs.js';
|
|
32
29
|
import { contextDir } from '../../../core/canvas/paths.js';
|
|
33
30
|
import { fillBar, fillWidth, stripSgr, REVERSE } from '../../../core/canvas/nav-render.js';
|
|
34
31
|
import { buildCanvasPanelLines } from './canvas-panels.js';
|
|
35
|
-
/** Seed pane focus from the pane's REAL active state, not an unconditional
|
|
36
|
-
* `true`. tmux only emits DECSET 1004 focus events on a TRANSITION, so a viewer
|
|
37
|
-
* spawned into a non-active pane (a `split -d` beside another node — the
|
|
38
|
-
* auto-viewer for a background child) starts unfocused and NEVER receives a
|
|
39
|
-
* FOCUS_OUT, leaving a solid cursor drawn until the user swaps in and back out.
|
|
40
|
-
* Query `#{pane_active}` for our own pane once at startup so an unfocused split
|
|
41
|
-
* renders hollow immediately; default true when not in tmux or the query fails. */
|
|
42
|
-
function initialPaneActive() {
|
|
43
|
-
const pane = process.env['TMUX_PANE'];
|
|
44
|
-
if (pane === undefined || pane === '')
|
|
45
|
-
return true;
|
|
46
|
-
try {
|
|
47
|
-
const out = execFileSync('tmux', ['display-message', '-p', '-t', pane, '#{pane_active}'], { encoding: 'utf8' }).trim();
|
|
48
|
-
return out !== '0';
|
|
49
|
-
}
|
|
50
|
-
catch {
|
|
51
|
-
return true;
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
32
|
/** How long an armed `x` waits for its `y`. */
|
|
55
33
|
const ARM_TIMEOUT_MS = 15_000;
|
|
56
34
|
export function createRoster(s, hooks) {
|
|
@@ -63,18 +41,14 @@ export function createRoster(s, hooks) {
|
|
|
63
41
|
let autoSelect = process.env['CRTR_ATTACH_ROSTER'];
|
|
64
42
|
if (autoSelect !== undefined)
|
|
65
43
|
delete process.env['CRTR_ATTACH_ROSTER'];
|
|
66
|
-
let paneFocused = initialPaneActive();
|
|
67
44
|
// Armed job cancellation: `x` on the bash row names the job, `y` confirms.
|
|
68
45
|
// Held here (not in the item list) so any other key, a selection move, or a
|
|
69
46
|
// rebuild that retires the job drops it — a stale confirmation must never
|
|
70
47
|
// kill a job the user is no longer looking at.
|
|
71
48
|
let pendingKill;
|
|
72
49
|
let killTimer;
|
|
73
|
-
const syncCursorDim = () => {
|
|
74
|
-
editor.paneFocused = paneFocused && selKey === undefined;
|
|
75
|
-
};
|
|
76
50
|
const paint = () => {
|
|
77
|
-
|
|
51
|
+
hooks.syncCursor();
|
|
78
52
|
reports.clear();
|
|
79
53
|
for (const item of items) {
|
|
80
54
|
const selected = item.key === selKey;
|
|
@@ -191,15 +165,6 @@ export function createRoster(s, hooks) {
|
|
|
191
165
|
const item = items.find((candidate) => candidate.key === selKey);
|
|
192
166
|
if (item === undefined || remote)
|
|
193
167
|
return;
|
|
194
|
-
if (item.kind === 'human') {
|
|
195
|
-
if (role !== 'controller') {
|
|
196
|
-
hooks.setNotice('Read-only observer attach — cannot open the human inbox');
|
|
197
|
-
return;
|
|
198
|
-
}
|
|
199
|
-
exit();
|
|
200
|
-
execFile('hl', ['inbox', 'open'], () => { });
|
|
201
|
-
return;
|
|
202
|
-
}
|
|
203
168
|
if (item.kind === 'crons') {
|
|
204
169
|
exit();
|
|
205
170
|
hooks.openInspector('schedule');
|
|
@@ -306,9 +271,5 @@ export function createRoster(s, hooks) {
|
|
|
306
271
|
exit();
|
|
307
272
|
return false;
|
|
308
273
|
};
|
|
309
|
-
|
|
310
|
-
paneFocused = focused;
|
|
311
|
-
syncCursorDim();
|
|
312
|
-
};
|
|
313
|
-
return { refresh, handleKey, setPaneFocused };
|
|
274
|
+
return { refresh, handleKey, active: () => selKey !== undefined, exit };
|
|
314
275
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Component } from '@earendil-works/pi-tui';
|
|
2
|
+
import type { TicketSummary } from '../../../core/human/types.js';
|
|
3
|
+
import type { AttachSession } from '../session/context.js';
|
|
4
|
+
export type PanelSession = Pick<AttachSession, 'tui' | 'pal'>;
|
|
5
|
+
export interface TicketPanelHooks {
|
|
6
|
+
/** Recompute the editor's hollow-cursor state (session/pane-focus.ts). */
|
|
7
|
+
syncCursor: () => void;
|
|
8
|
+
/** Repaint the strip — its rows hide while the panel is their expansion. */
|
|
9
|
+
refreshStrip: () => void;
|
|
10
|
+
setNotice: (msg: string) => void;
|
|
11
|
+
/** Fallback: the full-pane inbox surface seated on this ticket dir. */
|
|
12
|
+
openInbox: (dir: string) => void;
|
|
13
|
+
/** Release the terminal to $EDITOR for the comment roundtrip. */
|
|
14
|
+
suspendForChild: () => () => void;
|
|
15
|
+
}
|
|
16
|
+
export interface TicketPanel {
|
|
17
|
+
/** Mounted once into the frame's `ticketPanel` slot; empty while closed. */
|
|
18
|
+
readonly component: Component;
|
|
19
|
+
/** Open a ticket's page inline; review/unreadable tickets fall back. */
|
|
20
|
+
open(ticket: TicketSummary): void;
|
|
21
|
+
/** Panel key routing — consumes EVERY chunk while open. */
|
|
22
|
+
handleKey(data: string): boolean;
|
|
23
|
+
close(): void;
|
|
24
|
+
/** True while a page is open (the editor's cursor hollows). */
|
|
25
|
+
active(): boolean;
|
|
26
|
+
/** The open ticket's dir, for escalating to the full inbox surface. */
|
|
27
|
+
currentDir(): string | undefined;
|
|
28
|
+
}
|
|
29
|
+
export declare function createTicketPanel(s: PanelSession, hooks: TicketPanelHooks): TicketPanel;
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// chrome/ticket-panel.ts — the inline ticket page at the top of the pane.
|
|
2
|
+
//
|
|
3
|
+
// Enter on an inbox-strip row opens the ticket's page HERE, directly beneath
|
|
4
|
+
// the recap header where the strip's notification row sat — the row expands
|
|
5
|
+
// into the full page. It takes only the rows the page needs, growing all the
|
|
6
|
+
// way down to the cockpit for a tall page (the transcript compresses to zero
|
|
7
|
+
// before the editor gives a row), instead of the full-pane inbox surface.
|
|
8
|
+
// While open it owns every keystroke — pages carry text inputs — so the
|
|
9
|
+
// global key listener routes chunks to `handleKey` ahead of the strip and
|
|
10
|
+
// roster; Esc at the page top closes back to the editor.
|
|
11
|
+
//
|
|
12
|
+
// The claim lifecycle mirrors the inbox surface's detail screen: claim on
|
|
13
|
+
// open, heartbeat every 10s, watch the ticket dir (page.json → reload, other
|
|
14
|
+
// activity → close if resolved elsewhere), resolve through the daemon on
|
|
15
|
+
// complete, release on close. Review tickets and unreadable pages fall back
|
|
16
|
+
// to the full-pane inbox seated on the row.
|
|
17
|
+
import { watch } from 'node:fs';
|
|
18
|
+
import { claimTicket, heartbeatClaim, releaseClaim } from '../../../core/human/claim.js';
|
|
19
|
+
import { readPageTicket, readTicketResult } from '../../../core/human/tickets.js';
|
|
20
|
+
import { resolvePageComponents } from '../../../core/config.js';
|
|
21
|
+
import { fillBar, stripSgr, truncate, REVERSE } from '../../../core/canvas/nav-render.js';
|
|
22
|
+
import { visibleWidth } from '../../inbox/tui/ansi.js';
|
|
23
|
+
import { PageAdapter } from '../../inbox/page-adapter.js';
|
|
24
|
+
import { decodeInboxKey } from '../../inbox/tui/keys.js';
|
|
25
|
+
import { daemonErrorMessage, resolveFailureKind, resolveTicketPage } from '../../inbox/resolve.js';
|
|
26
|
+
import { editBufferInEditor } from '../../inbox/review/roundtrip.js';
|
|
27
|
+
export function createTicketPanel(s, hooks) {
|
|
28
|
+
const { tui, pal } = s;
|
|
29
|
+
let session;
|
|
30
|
+
let submitting = false;
|
|
31
|
+
let lastCols = 0;
|
|
32
|
+
let lastRows = 0;
|
|
33
|
+
/** Content rows offered to the page: everything but the recap header and the
|
|
34
|
+
* cockpit's minimum (activity · padding · editor · status) plus our own
|
|
35
|
+
* title bar and closing rule. The frame clips to the rows actually free. */
|
|
36
|
+
const capRows = () => Math.max(3, tui.terminal.rows - 10);
|
|
37
|
+
const closeSession = (release) => {
|
|
38
|
+
const open = session;
|
|
39
|
+
if (open === undefined)
|
|
40
|
+
return;
|
|
41
|
+
session = undefined;
|
|
42
|
+
clearInterval(open.heartbeat);
|
|
43
|
+
open.watcher?.close();
|
|
44
|
+
open.adapter.close();
|
|
45
|
+
if (release)
|
|
46
|
+
releaseClaim(open.dir, open.token);
|
|
47
|
+
hooks.syncCursor();
|
|
48
|
+
hooks.refreshStrip();
|
|
49
|
+
tui.requestRender();
|
|
50
|
+
};
|
|
51
|
+
const complete = async (open, responses) => {
|
|
52
|
+
submitting = true;
|
|
53
|
+
try {
|
|
54
|
+
await resolveTicketPage(open.dir, responses);
|
|
55
|
+
closeSession(false);
|
|
56
|
+
hooks.setNotice('Response sent');
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
const failure = resolveFailureKind(error);
|
|
60
|
+
if (failure === 'unreachable' || failure === 'other') {
|
|
61
|
+
hooks.setNotice(daemonErrorMessage(error));
|
|
62
|
+
return; // keep the page open — nothing was submitted
|
|
63
|
+
}
|
|
64
|
+
closeSession(false);
|
|
65
|
+
hooks.setNotice(failure === 'already_resolved' ? resolvedElsewhereNotice(open.dir) : daemonErrorMessage(error));
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
submitting = false;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
const reload = (open) => {
|
|
72
|
+
try {
|
|
73
|
+
open.adapter.reload(readPageTicket(open.dir));
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
hooks.setNotice('Page could not be read');
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
const editPageInput = (open) => {
|
|
80
|
+
const buffer = open.adapter.inputBuffer();
|
|
81
|
+
if (buffer === undefined)
|
|
82
|
+
return;
|
|
83
|
+
const resume = hooks.suspendForChild();
|
|
84
|
+
let result = { text: buffer };
|
|
85
|
+
try {
|
|
86
|
+
result = editBufferInEditor(buffer);
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
resume();
|
|
90
|
+
open.adapter.setInputBuffer(result.text);
|
|
91
|
+
if (result.error !== undefined)
|
|
92
|
+
hooks.setNotice(result.error);
|
|
93
|
+
tui.requestRender();
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
const open = (ticket) => {
|
|
97
|
+
if (session !== undefined)
|
|
98
|
+
closeSession(true);
|
|
99
|
+
if (ticket.kind !== 'page') {
|
|
100
|
+
hooks.openInbox(ticket.dir);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const claim = claimTicket(ticket.dir);
|
|
104
|
+
if (claim === null) {
|
|
105
|
+
hooks.setNotice('Ticket is being edited by another inbox');
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
let loaded;
|
|
109
|
+
try {
|
|
110
|
+
loaded = readPageTicket(ticket.dir);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
releaseClaim(ticket.dir, claim.token);
|
|
114
|
+
hooks.openInbox(ticket.dir);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
lastCols = Math.max(20, tui.terminal.columns - 2);
|
|
118
|
+
lastRows = capRows();
|
|
119
|
+
const entry = {
|
|
120
|
+
dir: ticket.dir,
|
|
121
|
+
title: ticket.title,
|
|
122
|
+
token: claim.token,
|
|
123
|
+
adapter: new PageAdapter({
|
|
124
|
+
dir: ticket.dir,
|
|
125
|
+
ticket: loaded,
|
|
126
|
+
productKinds: resolvePageComponents(),
|
|
127
|
+
cols: lastCols,
|
|
128
|
+
rows: lastRows,
|
|
129
|
+
onDirty: () => tui.requestRender(),
|
|
130
|
+
onBack: () => closeSession(true),
|
|
131
|
+
onComplete: (responses) => { void complete(entry, responses); },
|
|
132
|
+
onEditorRequest: () => editPageInput(entry),
|
|
133
|
+
}),
|
|
134
|
+
watcher: undefined,
|
|
135
|
+
heartbeat: setInterval(() => {
|
|
136
|
+
if (session === entry)
|
|
137
|
+
heartbeatClaim(entry.dir, entry.token);
|
|
138
|
+
}, 10_000),
|
|
139
|
+
};
|
|
140
|
+
try {
|
|
141
|
+
entry.watcher = watch(ticket.dir, (_event, file) => {
|
|
142
|
+
if (session !== entry || submitting)
|
|
143
|
+
return;
|
|
144
|
+
if (file === 'page.json') {
|
|
145
|
+
reload(entry);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const result = readTicketResult(entry.dir);
|
|
149
|
+
if (result !== null) {
|
|
150
|
+
closeSession(true);
|
|
151
|
+
hooks.setNotice(result.kind === 'canceled' ? 'Ticket was canceled by its requester' : 'Ticket was resolved elsewhere');
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
entry.watcher = undefined;
|
|
157
|
+
}
|
|
158
|
+
session = entry;
|
|
159
|
+
hooks.syncCursor();
|
|
160
|
+
hooks.refreshStrip();
|
|
161
|
+
tui.requestRender();
|
|
162
|
+
};
|
|
163
|
+
const handleKey = (data) => {
|
|
164
|
+
const entry = session;
|
|
165
|
+
if (entry === undefined)
|
|
166
|
+
return false;
|
|
167
|
+
const { input, key } = decodeInboxKey(data);
|
|
168
|
+
entry.adapter.handleKey(input, key);
|
|
169
|
+
tui.requestRender();
|
|
170
|
+
return true;
|
|
171
|
+
};
|
|
172
|
+
const component = {
|
|
173
|
+
render(width) {
|
|
174
|
+
const entry = session;
|
|
175
|
+
if (entry === undefined)
|
|
176
|
+
return [];
|
|
177
|
+
const cols = Math.max(20, width - 2);
|
|
178
|
+
const rows = capRows();
|
|
179
|
+
if (cols !== lastCols || rows !== lastRows) {
|
|
180
|
+
lastCols = cols;
|
|
181
|
+
lastRows = rows;
|
|
182
|
+
entry.adapter.resize(cols, rows);
|
|
183
|
+
}
|
|
184
|
+
// The strip row's highlight carries into the open page: the same
|
|
185
|
+
// reversed full-width bar, now a title bar, with a closing rule beneath
|
|
186
|
+
// the content so the page reads as one bounded region over the chat.
|
|
187
|
+
const header = fillBar(stripSgr(truncate(` ⚑ ${entry.title} · Esc closes`, width)), width, REVERSE);
|
|
188
|
+
// The body sits on the same background the crouter context cards use, so
|
|
189
|
+
// the open ticket reads as one solid panel distinct from the chat.
|
|
190
|
+
const bg = contextCardBg();
|
|
191
|
+
const body = trimTrailingBlank(entry.adapter.render()).map((line) => paintBg(` ${line}`, width, bg));
|
|
192
|
+
return [header, ...body, pal.border('─'.repeat(Math.max(0, width)))];
|
|
193
|
+
},
|
|
194
|
+
invalidate() { },
|
|
195
|
+
};
|
|
196
|
+
return {
|
|
197
|
+
component,
|
|
198
|
+
open,
|
|
199
|
+
handleKey,
|
|
200
|
+
close: () => closeSession(true),
|
|
201
|
+
active: () => session !== undefined,
|
|
202
|
+
currentDir: () => session?.dir,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
function resolvedElsewhereNotice(dir) {
|
|
206
|
+
return readTicketResult(dir)?.kind === 'canceled' ? 'Ticket was already canceled' : 'Ticket was already resolved';
|
|
207
|
+
}
|
|
208
|
+
/** The SGR opening the theme's `customMessageBg` — the background the crouter
|
|
209
|
+
* context cards use. pi shares its live theme across module loaders on this
|
|
210
|
+
* well-known global (it exports no direct handle); a missing or incomplete
|
|
211
|
+
* theme just means no fill, never a throw. */
|
|
212
|
+
function contextCardBg() {
|
|
213
|
+
const t = globalThis[Symbol.for('@earendil-works/pi-coding-agent:theme')];
|
|
214
|
+
try {
|
|
215
|
+
return t?.getBgAnsi?.('customMessageBg');
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/** Fill one row with `bg` edge to edge, re-asserting it across the line's own
|
|
222
|
+
* full resets and background resets so interior styling cannot punch holes. */
|
|
223
|
+
function paintBg(line, width, bg) {
|
|
224
|
+
if (bg === undefined)
|
|
225
|
+
return line;
|
|
226
|
+
const kept = line.replaceAll('\x1b[0m', `\x1b[0m${bg}`).replaceAll('\x1b[49m', bg);
|
|
227
|
+
const pad = ' '.repeat(Math.max(0, width - visibleWidth(line)));
|
|
228
|
+
return `${bg}${kept}${pad}\x1b[49m`;
|
|
229
|
+
}
|
|
230
|
+
/** Short pages take only their rows: the adapter pads to its rectangle. */
|
|
231
|
+
function trimTrailingBlank(lines) {
|
|
232
|
+
let end = lines.length;
|
|
233
|
+
while (end > 0 && stripSgr(lines[end - 1]).trim() === '')
|
|
234
|
+
end--;
|
|
235
|
+
return lines.slice(0, end);
|
|
236
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { type GeneratedCard } from '../../../shared/generated-context.js';
|
|
2
2
|
/** What the one global detail toggle reveals for this kind.
|
|
3
|
-
* `section` names an element in the body; everything outside it stays folded.
|
|
3
|
+
* `section` names an element in the body; everything outside it stays folded.
|
|
4
|
+
* `render` builds Markdown from the parsed card, for a body whose raw markup
|
|
5
|
+
* is not what a person should read. */
|
|
4
6
|
export type CardExpandedPolicy = {
|
|
5
7
|
readonly disclose: 'never';
|
|
6
8
|
} | {
|
|
@@ -8,6 +10,9 @@ export type CardExpandedPolicy = {
|
|
|
8
10
|
} | {
|
|
9
11
|
readonly disclose: 'section';
|
|
10
12
|
readonly section: string;
|
|
13
|
+
} | {
|
|
14
|
+
readonly disclose: 'render';
|
|
15
|
+
readonly render: (card: GeneratedCard) => string;
|
|
11
16
|
};
|
|
12
17
|
/** What is left of the card once its turn folds into one recap line. A boundary
|
|
13
18
|
* kind never reaches this field — `isGroupBoundaryKind` removes it from the
|
|
@@ -24,8 +29,8 @@ export interface CardPresentation {
|
|
|
24
29
|
export declare function cardLabel(subject: string): string;
|
|
25
30
|
/** The disclosure a card actually offers, given its policy and body. A kind
|
|
26
31
|
* whose section is missing from an old transcript offers nothing to expand. */
|
|
27
|
-
export declare function cardDisclosure(presentation: CardPresentation,
|
|
28
|
-
export declare function cardIsExpandable(presentation: CardPresentation,
|
|
32
|
+
export declare function cardDisclosure(presentation: CardPresentation, card: GeneratedCard): string;
|
|
33
|
+
export declare function cardIsExpandable(presentation: CardPresentation, card: GeneratedCard): boolean;
|
|
29
34
|
/** The one lookup. Every display surface deciding how a card behaves resolves it
|
|
30
35
|
* here, so row, disclosure, and recap can never disagree about a kind. */
|
|
31
36
|
export declare function presentationFor(kind: string): CardPresentation;
|