@jhizzard/termdeck 0.18.0 → 1.0.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.
- package/package.json +1 -1
- package/packages/client/public/app.js +131 -1
- package/packages/server/src/agent-adapters/claude.js +55 -0
- package/packages/server/src/agent-adapters/codex.js +82 -0
- package/packages/server/src/agent-adapters/gemini.js +57 -0
- package/packages/server/src/agent-adapters/grok.js +82 -0
- package/packages/server/src/index.js +132 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jhizzard/termdeck",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "Browser-based terminal multiplexer with metadata overlays, panel flashback memory recall, and AI-aware session management",
|
|
5
5
|
"bin": {
|
|
6
6
|
"termdeck": "./packages/cli/src/index.js"
|
|
@@ -53,6 +53,20 @@
|
|
|
53
53
|
}
|
|
54
54
|
} catch (_) { /* keep bootstrap fallback */ }
|
|
55
55
|
|
|
56
|
+
// Sprint 50 T3 — adapter-driven launcher buttons. Render one button
|
|
57
|
+
// per registered agent in the topbar quick-launch and the empty-state
|
|
58
|
+
// tile group. Replaces the pre-Sprint-50 hardcoded `claude` button
|
|
59
|
+
// that left Codex/Gemini/Grok with no one-click launcher (forcing
|
|
60
|
+
// free-form `codex`/`gemini`/`grok` typing in the prompt bar — a v1.0.0
|
|
61
|
+
// gate-blocker UX gap surfaced during the Sprint 49 mixed-agent
|
|
62
|
+
// dogfood). Static `shell` + `python` entries stay (non-adapter
|
|
63
|
+
// built-ins). HTML fallback shapes are preserved if rendering fails.
|
|
64
|
+
try {
|
|
65
|
+
renderQuickLaunchers();
|
|
66
|
+
} catch (err) {
|
|
67
|
+
console.warn('[client] launcher render failed, keeping HTML fallback:', err);
|
|
68
|
+
}
|
|
69
|
+
|
|
56
70
|
// Populate project dropdown
|
|
57
71
|
const sel = document.getElementById('promptProject');
|
|
58
72
|
for (const name of Object.keys(state.config.projects || {})) {
|
|
@@ -1586,6 +1600,98 @@
|
|
|
1586
1600
|
launchTerminal();
|
|
1587
1601
|
}
|
|
1588
1602
|
|
|
1603
|
+
// ===== Adapter-driven launcher buttons (Sprint 50 T3) =====
|
|
1604
|
+
//
|
|
1605
|
+
// Built-in non-adapter entries that flank the adapter list. `shell` is
|
|
1606
|
+
// the always-on fallback panel; `python` is the HTTP-server convenience
|
|
1607
|
+
// launcher that long predates the multi-agent registry.
|
|
1608
|
+
const BUILTIN_LAUNCHERS = {
|
|
1609
|
+
pre: [
|
|
1610
|
+
{ command: 'zsh', label: 'shell', title: 'Open a zsh shell' },
|
|
1611
|
+
],
|
|
1612
|
+
post: [
|
|
1613
|
+
{
|
|
1614
|
+
command: 'python3 -m http.server 8080',
|
|
1615
|
+
label: 'python',
|
|
1616
|
+
title: 'Open a Python HTTP server on :8080',
|
|
1617
|
+
},
|
|
1618
|
+
],
|
|
1619
|
+
};
|
|
1620
|
+
|
|
1621
|
+
// One launcher button. Reuses the same `quickLaunch(cmd)` path the
|
|
1622
|
+
// hardcoded HTML buttons used so command resolution (LauncherResolver
|
|
1623
|
+
// + /api/sessions) is unchanged.
|
|
1624
|
+
function makeLauncherButton(cmd, label, title, className) {
|
|
1625
|
+
const btn = document.createElement('button');
|
|
1626
|
+
btn.type = 'button';
|
|
1627
|
+
btn.className = className;
|
|
1628
|
+
btn.textContent = label;
|
|
1629
|
+
if (title) btn.title = title;
|
|
1630
|
+
btn.dataset.command = cmd;
|
|
1631
|
+
btn.addEventListener('click', () => quickLaunch(cmd));
|
|
1632
|
+
return btn;
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
function adapterLauncherEntries() {
|
|
1636
|
+
const adapters = Array.isArray(state.agentAdapters) ? state.agentAdapters : [];
|
|
1637
|
+
return adapters.map((a) => ({
|
|
1638
|
+
command: a.binary || a.name,
|
|
1639
|
+
label: (a.displayName || a.name || a.binary || '').toLowerCase(),
|
|
1640
|
+
// Title text gets the canonical displayName so the tooltip preserves
|
|
1641
|
+
// the proper-cased "Claude Code" / "Codex CLI" form even when the
|
|
1642
|
+
// button face renders lowercase to match TermDeck's chrome style.
|
|
1643
|
+
title: `Open ${a.displayName || a.name || a.binary}`,
|
|
1644
|
+
}));
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
function renderQuickLaunchers() {
|
|
1648
|
+
const adapters = adapterLauncherEntries();
|
|
1649
|
+
const ordered = [
|
|
1650
|
+
...BUILTIN_LAUNCHERS.pre,
|
|
1651
|
+
...adapters,
|
|
1652
|
+
...BUILTIN_LAUNCHERS.post,
|
|
1653
|
+
];
|
|
1654
|
+
|
|
1655
|
+
// Topbar — compact buttons.
|
|
1656
|
+
const topbar = document.getElementById('topbarQuickLaunch');
|
|
1657
|
+
if (topbar) {
|
|
1658
|
+
topbar.replaceChildren();
|
|
1659
|
+
for (const entry of ordered) {
|
|
1660
|
+
topbar.appendChild(
|
|
1661
|
+
makeLauncherButton(entry.command, entry.label, entry.title, 'topbar-ql-btn'),
|
|
1662
|
+
);
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
// Empty state — taller tiles with the raw command rendered as a
|
|
1667
|
+
// secondary line. Mirrors the pre-Sprint-50 markup so existing CSS
|
|
1668
|
+
// classes (`quick-launch-btn`, `ql-cmd`, `ql-desc`) keep their styling.
|
|
1669
|
+
const emptyGroup = document.querySelector('#emptyState .quick-launch-group');
|
|
1670
|
+
if (emptyGroup) {
|
|
1671
|
+
emptyGroup.replaceChildren();
|
|
1672
|
+
for (const entry of ordered) {
|
|
1673
|
+
const btn = document.createElement('button');
|
|
1674
|
+
btn.type = 'button';
|
|
1675
|
+
btn.className = 'quick-launch-btn';
|
|
1676
|
+
btn.title = entry.title;
|
|
1677
|
+
btn.dataset.command = entry.command;
|
|
1678
|
+
|
|
1679
|
+
const cmd = document.createElement('span');
|
|
1680
|
+
cmd.className = 'ql-cmd';
|
|
1681
|
+
cmd.textContent = entry.command;
|
|
1682
|
+
|
|
1683
|
+
const desc = document.createElement('span');
|
|
1684
|
+
desc.className = 'ql-desc';
|
|
1685
|
+
desc.textContent = entry.title;
|
|
1686
|
+
|
|
1687
|
+
btn.appendChild(cmd);
|
|
1688
|
+
btn.appendChild(desc);
|
|
1689
|
+
btn.addEventListener('click', () => quickLaunch(entry.command));
|
|
1690
|
+
emptyGroup.appendChild(btn);
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1589
1695
|
// ===== Add Project modal =====
|
|
1590
1696
|
function rebuildProjectDropdown(selectName) {
|
|
1591
1697
|
const sel = document.getElementById('promptProject');
|
|
@@ -2571,10 +2677,23 @@
|
|
|
2571
2677
|
}
|
|
2572
2678
|
|
|
2573
2679
|
function getTypeLabel(type) {
|
|
2680
|
+
// Sprint 50 T3 — adapter-driven panel header labels. Consult
|
|
2681
|
+
// state.agentAdapters first so a freshly-launched Codex/Gemini/Grok
|
|
2682
|
+
// panel reads its agent's displayName (rather than the raw
|
|
2683
|
+
// sessionType string or — worse — falling through to "Shell" when
|
|
2684
|
+
// the type label map didn't have an entry). Adding a new agent now
|
|
2685
|
+
// requires only an adapter file with `displayName`; no client-side
|
|
2686
|
+
// edit. Built-in non-adapter types (shell / python-server / etc.)
|
|
2687
|
+
// keep their static labels.
|
|
2688
|
+
const adapters = Array.isArray(state.agentAdapters) ? state.agentAdapters : [];
|
|
2689
|
+
const adapter = adapters.find((a) => a && a.sessionType === type);
|
|
2690
|
+
if (adapter && adapter.displayName) return adapter.displayName;
|
|
2574
2691
|
const labels = {
|
|
2575
2692
|
'shell': 'Shell',
|
|
2576
2693
|
'claude-code': 'Claude Code',
|
|
2694
|
+
'codex': 'Codex CLI',
|
|
2577
2695
|
'gemini': 'Gemini CLI',
|
|
2696
|
+
'grok': 'Grok CLI',
|
|
2578
2697
|
'python-server': 'Python Server',
|
|
2579
2698
|
'one-shot': 'One-shot'
|
|
2580
2699
|
};
|
|
@@ -2624,7 +2743,18 @@
|
|
|
2624
2743
|
|
|
2625
2744
|
if (dot) {
|
|
2626
2745
|
dot.style.background = getStatusColor(meta.status);
|
|
2627
|
-
|
|
2746
|
+
// Sprint 50 T3 — pulse the status dot for ALL in-flight states
|
|
2747
|
+
// (thinking, editing, active), not just thinking. Pre-Sprint-50 the
|
|
2748
|
+
// dot only pulsed on `thinking`; during a long agent task the
|
|
2749
|
+
// status fluctuated through editing/active as different regex
|
|
2750
|
+
// patterns matched the live PTY stream, removing the pulsing class
|
|
2751
|
+
// each time and making the visual cue feel "frozen" between thinking
|
|
2752
|
+
// hits. Pulsing across all work-in-progress states keeps the
|
|
2753
|
+
// animation alive end-to-end. Idle / exited / errored stay solid.
|
|
2754
|
+
const inflight = meta.status === 'thinking'
|
|
2755
|
+
|| meta.status === 'editing'
|
|
2756
|
+
|| meta.status === 'active';
|
|
2757
|
+
dot.classList.toggle('pulsing', inflight);
|
|
2628
2758
|
}
|
|
2629
2759
|
if (status) status.textContent = meta.statusDetail || meta.status;
|
|
2630
2760
|
if (metaLast && meta.lastCommands?.length) {
|
|
@@ -75,6 +75,52 @@ function statusFor(data) {
|
|
|
75
75
|
return null;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
79
|
+
// resolveTranscriptPath — Sprint 50 T1 (10th adapter field).
|
|
80
|
+
//
|
|
81
|
+
// Claude Code stores per-session JSONL transcripts at
|
|
82
|
+
// ~/.claude/projects/<dir-hash>/<claude-session-uuid>.jsonl
|
|
83
|
+
// where <dir-hash> is `cwd` with `/` replaced by `-`. The inner UUID is
|
|
84
|
+
// Claude's own session id — distinct from TermDeck's `session.id` — so we
|
|
85
|
+
// can't compute the path; we list the directory and pick the most recently
|
|
86
|
+
// modified .jsonl whose mtime is at-or-after `session.meta.createdAt`.
|
|
87
|
+
//
|
|
88
|
+
// IMPORTANT: server-side `onPanelClose` SKIPS claude-typed sessions so
|
|
89
|
+
// Claude's existing SessionEnd hook (registered in ~/.claude/settings.json)
|
|
90
|
+
// remains the sole writer for Claude rows — no double-writes. This
|
|
91
|
+
// implementation exists for contract uniformity + the unit-test surface +
|
|
92
|
+
// future tooling that may want to look up Claude transcripts directly.
|
|
93
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
async function resolveTranscriptPath(session) {
|
|
96
|
+
const fs = require('fs');
|
|
97
|
+
const path = require('path');
|
|
98
|
+
const os = require('os');
|
|
99
|
+
if (!session || !session.meta || !session.meta.cwd) return null;
|
|
100
|
+
const dirHash = session.meta.cwd.replace(/\//g, '-');
|
|
101
|
+
const projectsDir = path.join(os.homedir(), '.claude', 'projects', dirHash);
|
|
102
|
+
let entries;
|
|
103
|
+
try { entries = fs.readdirSync(projectsDir); }
|
|
104
|
+
catch (_) { return null; }
|
|
105
|
+
const createdAtMs = session.meta.createdAt
|
|
106
|
+
? Date.parse(session.meta.createdAt)
|
|
107
|
+
: 0;
|
|
108
|
+
let bestPath = null;
|
|
109
|
+
let bestMtime = -Infinity;
|
|
110
|
+
for (const name of entries) {
|
|
111
|
+
if (!name.endsWith('.jsonl')) continue;
|
|
112
|
+
const full = path.join(projectsDir, name);
|
|
113
|
+
let st;
|
|
114
|
+
try { st = fs.statSync(full); } catch (_) { continue; }
|
|
115
|
+
if (createdAtMs && st.mtimeMs < createdAtMs) continue;
|
|
116
|
+
if (st.mtimeMs > bestMtime) {
|
|
117
|
+
bestMtime = st.mtimeMs;
|
|
118
|
+
bestPath = full;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return bestPath;
|
|
122
|
+
}
|
|
123
|
+
|
|
78
124
|
// ──────────────────────────────────────────────────────────────────────────
|
|
79
125
|
// parseTranscript — Claude Code JSONL format, lifted from
|
|
80
126
|
// packages/stack-installer/assets/hooks/memory-session-end.js:88-102.
|
|
@@ -137,6 +183,12 @@ function bootPromptTemplate(lane = {}, sprint = {}) {
|
|
|
137
183
|
const claudeAdapter = {
|
|
138
184
|
name: 'claude',
|
|
139
185
|
sessionType: 'claude-code',
|
|
186
|
+
// Sprint 50 T3 — human-readable label for launcher buttons + panel headers.
|
|
187
|
+
// Drives /api/agents projection and getTypeLabel() in the dashboard so a
|
|
188
|
+
// freshly-launched Codex/Gemini/Grok panel header reads its agent name
|
|
189
|
+
// (not the previous "Shell" fallback). Single source of truth — adding a
|
|
190
|
+
// new adapter no longer requires touching the client's hardcoded label map.
|
|
191
|
+
displayName: 'Claude Code',
|
|
140
192
|
matches: (cmd) => typeof cmd === 'string' && /claude/i.test(cmd),
|
|
141
193
|
spawn: {
|
|
142
194
|
binary: 'claude',
|
|
@@ -156,6 +208,9 @@ const claudeAdapter = {
|
|
|
156
208
|
},
|
|
157
209
|
statusFor,
|
|
158
210
|
parseTranscript,
|
|
211
|
+
// Sprint 50 T1 — 10th adapter field. See header for skip-claude rule
|
|
212
|
+
// (onPanelClose ignores claude-typed sessions).
|
|
213
|
+
resolveTranscriptPath,
|
|
159
214
|
bootPromptTemplate,
|
|
160
215
|
costBand: 'pay-per-token',
|
|
161
216
|
// Sprint 47 T3 — Claude Code's input box accepts bracketed-paste cleanly.
|
|
@@ -101,6 +101,83 @@ function statusFor(data) {
|
|
|
101
101
|
return null;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
105
|
+
// resolveTranscriptPath — Sprint 50 T1.
|
|
106
|
+
//
|
|
107
|
+
// Codex stores chat-shape JSONL rollouts at
|
|
108
|
+
// ~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl
|
|
109
|
+
// (verified 2026-05-02 substrate probe — first line is
|
|
110
|
+
// `{type:'session_meta', payload:{cwd, ...}}`). `~/.codex/history.jsonl` at
|
|
111
|
+
// the top level is a flat command-history shape, NOT chat — Sprint 49
|
|
112
|
+
// close-out tried that and got `session-too-short: 0 messages
|
|
113
|
+
// (parser=codex)` from the bundled hook against a real lane session.
|
|
114
|
+
//
|
|
115
|
+
// Attribution strategy: we don't know Codex's internal session UUID at
|
|
116
|
+
// spawn time, so we walk today's + yesterday's rollout directories in
|
|
117
|
+
// newest-mtime order, parse each file's first line, and return the first
|
|
118
|
+
// match where `session_meta.payload.cwd === session.meta.cwd` AND
|
|
119
|
+
// `mtime >= session.meta.createdAt`. Returns null when no rollout exists
|
|
120
|
+
// (e.g., Codex panel was opened but never sent a turn) — onPanelClose
|
|
121
|
+
// no-ops cleanly.
|
|
122
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
function _codexCandidateDirs(homedir, now) {
|
|
125
|
+
const path = require('path');
|
|
126
|
+
const day = new Date(now);
|
|
127
|
+
const yesterday = new Date(now - 24 * 60 * 60 * 1000);
|
|
128
|
+
const fmt = (d) => ({
|
|
129
|
+
Y: String(d.getUTCFullYear()),
|
|
130
|
+
M: String(d.getUTCMonth() + 1).padStart(2, '0'),
|
|
131
|
+
D: String(d.getUTCDate()).padStart(2, '0'),
|
|
132
|
+
});
|
|
133
|
+
const out = [];
|
|
134
|
+
for (const d of [day, yesterday]) {
|
|
135
|
+
const { Y, M, D } = fmt(d);
|
|
136
|
+
out.push(path.join(homedir, '.codex', 'sessions', Y, M, D));
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function resolveTranscriptPath(session) {
|
|
142
|
+
const fs = require('fs');
|
|
143
|
+
const path = require('path');
|
|
144
|
+
const os = require('os');
|
|
145
|
+
if (!session || !session.meta || !session.meta.cwd) return null;
|
|
146
|
+
const cwd = session.meta.cwd;
|
|
147
|
+
const createdAtMs = session.meta.createdAt
|
|
148
|
+
? Date.parse(session.meta.createdAt)
|
|
149
|
+
: 0;
|
|
150
|
+
const candidates = [];
|
|
151
|
+
for (const dir of _codexCandidateDirs(os.homedir(), Date.now())) {
|
|
152
|
+
let entries;
|
|
153
|
+
try { entries = fs.readdirSync(dir); }
|
|
154
|
+
catch (_) { continue; }
|
|
155
|
+
for (const name of entries) {
|
|
156
|
+
if (!name.startsWith('rollout-') || !name.endsWith('.jsonl')) continue;
|
|
157
|
+
const full = path.join(dir, name);
|
|
158
|
+
let st;
|
|
159
|
+
try { st = fs.statSync(full); } catch (_) { continue; }
|
|
160
|
+
if (createdAtMs && st.mtimeMs < createdAtMs) continue;
|
|
161
|
+
candidates.push({ full, mtime: st.mtimeMs });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
candidates.sort((a, b) => b.mtime - a.mtime);
|
|
165
|
+
for (const { full } of candidates) {
|
|
166
|
+
let firstLine;
|
|
167
|
+
try {
|
|
168
|
+
const buf = fs.readFileSync(full, 'utf8');
|
|
169
|
+
const nl = buf.indexOf('\n');
|
|
170
|
+
firstLine = nl >= 0 ? buf.slice(0, nl) : buf;
|
|
171
|
+
} catch (_) { continue; }
|
|
172
|
+
let meta;
|
|
173
|
+
try { meta = JSON.parse(firstLine); } catch (_) { continue; }
|
|
174
|
+
if (!meta || meta.type !== 'session_meta') continue;
|
|
175
|
+
if (!meta.payload || meta.payload.cwd !== cwd) continue;
|
|
176
|
+
return full;
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
104
181
|
// ──────────────────────────────────────────────────────────────────────────
|
|
105
182
|
// parseTranscript — Codex JSONL format.
|
|
106
183
|
//
|
|
@@ -173,6 +250,8 @@ function bootPromptTemplate(lane = {}, sprint = {}) {
|
|
|
173
250
|
const codexAdapter = {
|
|
174
251
|
name: 'codex',
|
|
175
252
|
sessionType: 'codex',
|
|
253
|
+
// Sprint 50 T3 — see claude.js for rationale.
|
|
254
|
+
displayName: 'Codex CLI',
|
|
176
255
|
matches: (cmd) => typeof cmd === 'string' && /\bcodex\b/i.test(cmd),
|
|
177
256
|
spawn: {
|
|
178
257
|
binary: 'codex',
|
|
@@ -192,6 +271,9 @@ const codexAdapter = {
|
|
|
192
271
|
},
|
|
193
272
|
statusFor,
|
|
194
273
|
parseTranscript,
|
|
274
|
+
// Sprint 50 T1 — 10th adapter field. See header above for substrate
|
|
275
|
+
// findings + attribution strategy.
|
|
276
|
+
resolveTranscriptPath,
|
|
195
277
|
bootPromptTemplate,
|
|
196
278
|
costBand: 'pay-per-token',
|
|
197
279
|
// Sprint 47 T3 — Codex's Ratatui TUI accepts bracketed-paste per the
|
|
@@ -46,6 +46,59 @@ function statusFor(data) {
|
|
|
46
46
|
return null;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
50
|
+
// resolveTranscriptPath — Sprint 50 T1.
|
|
51
|
+
//
|
|
52
|
+
// Gemini CLI persists chats at
|
|
53
|
+
// ~/.gemini/tmp/<basename(cwd)>/chats/session-<ISO-ts>-<short-id>.json
|
|
54
|
+
// (single-JSON-object shape that matches parseGeminiJson, verified
|
|
55
|
+
// 2026-05-02 substrate probe). Pick the most recently modified file whose
|
|
56
|
+
// mtime is at-or-after `session.meta.createdAt`. Falls back to walking
|
|
57
|
+
// every project directory under `~/.gemini/tmp/*/chats/` if the basename
|
|
58
|
+
// heuristic produces no candidate (e.g., Gemini renormalized the project
|
|
59
|
+
// name to deduplicate against an existing one).
|
|
60
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
async function resolveTranscriptPath(session) {
|
|
63
|
+
const fs = require('fs');
|
|
64
|
+
const path = require('path');
|
|
65
|
+
const os = require('os');
|
|
66
|
+
if (!session || !session.meta || !session.meta.cwd) return null;
|
|
67
|
+
const createdAtMs = session.meta.createdAt
|
|
68
|
+
? Date.parse(session.meta.createdAt)
|
|
69
|
+
: 0;
|
|
70
|
+
const tmpRoot = path.join(os.homedir(), '.gemini', 'tmp');
|
|
71
|
+
const cwdBase = path.basename(session.meta.cwd);
|
|
72
|
+
const primary = path.join(tmpRoot, cwdBase, 'chats');
|
|
73
|
+
const extras = [];
|
|
74
|
+
try {
|
|
75
|
+
for (const proj of fs.readdirSync(tmpRoot)) {
|
|
76
|
+
const candidate = path.join(tmpRoot, proj, 'chats');
|
|
77
|
+
if (candidate !== primary) extras.push(candidate);
|
|
78
|
+
}
|
|
79
|
+
} catch (_) { /* tmp root absent */ }
|
|
80
|
+
let bestPath = null;
|
|
81
|
+
let bestMtime = -Infinity;
|
|
82
|
+
const scan = (dir) => {
|
|
83
|
+
let entries;
|
|
84
|
+
try { entries = fs.readdirSync(dir); } catch (_) { return; }
|
|
85
|
+
for (const name of entries) {
|
|
86
|
+
if (!name.startsWith('session-') || !name.endsWith('.json')) continue;
|
|
87
|
+
const full = path.join(dir, name);
|
|
88
|
+
let st;
|
|
89
|
+
try { st = fs.statSync(full); } catch (_) { continue; }
|
|
90
|
+
if (createdAtMs && st.mtimeMs < createdAtMs) continue;
|
|
91
|
+
if (st.mtimeMs > bestMtime) {
|
|
92
|
+
bestMtime = st.mtimeMs;
|
|
93
|
+
bestPath = full;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
scan(primary);
|
|
98
|
+
if (!bestPath) for (const dir of extras) scan(dir);
|
|
99
|
+
return bestPath;
|
|
100
|
+
}
|
|
101
|
+
|
|
49
102
|
// ──────────────────────────────────────────────────────────────────────────
|
|
50
103
|
// parseTranscript — Gemini CLI session JSON format (NOT JSONL).
|
|
51
104
|
//
|
|
@@ -177,6 +230,8 @@ function buildMnestraBlock({ secrets } = {}) {
|
|
|
177
230
|
const geminiAdapter = {
|
|
178
231
|
name: 'gemini',
|
|
179
232
|
sessionType: 'gemini',
|
|
233
|
+
// Sprint 50 T3 — see claude.js for rationale.
|
|
234
|
+
displayName: 'Gemini CLI',
|
|
180
235
|
matches: (cmd) => typeof cmd === 'string' && /gemini/i.test(cmd),
|
|
181
236
|
spawn: {
|
|
182
237
|
binary: 'gemini',
|
|
@@ -199,6 +254,8 @@ const geminiAdapter = {
|
|
|
199
254
|
},
|
|
200
255
|
statusFor,
|
|
201
256
|
parseTranscript,
|
|
257
|
+
// Sprint 50 T1 — 10th adapter field. Walks ~/.gemini/tmp/<proj>/chats.
|
|
258
|
+
resolveTranscriptPath,
|
|
202
259
|
bootPromptTemplate,
|
|
203
260
|
costBand: 'pay-per-token',
|
|
204
261
|
// Sprint 47 T3 — Gemini's CLI is paste-friendly per the single-JSON-object
|
|
@@ -121,6 +121,83 @@ function statusFor(data) {
|
|
|
121
121
|
return null;
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
125
|
+
// resolveTranscriptPath — Sprint 50 T1.
|
|
126
|
+
//
|
|
127
|
+
// Grok stores messages in `~/.grok/grok.db` (SQLite, STRICT schema requiring
|
|
128
|
+
// SQLite ≥3.37 — macOS system sqlite3 3.36 cannot read it; better-sqlite3
|
|
129
|
+
// bundles a recent build). The bundled hook (vendored to ~/.claude/hooks/)
|
|
130
|
+
// can't `require('better-sqlite3')` because that path is outside TermDeck's
|
|
131
|
+
// node_modules tree. So `resolveTranscriptPath` does the SQLite extraction
|
|
132
|
+
// in-process here (the server has better-sqlite3 as a top-level dep), writes
|
|
133
|
+
// the messages as a JSON envelope to `os.tmpdir()/termdeck-grok-<id>.json`,
|
|
134
|
+
// and returns the tempfile path. The hook then reads that path with
|
|
135
|
+
// `parseGrokJson` (a flat JSON-array parser — no SQLite needed downstream).
|
|
136
|
+
//
|
|
137
|
+
// Workspace mapping: grok.db's `workspaces.canonical_path` is the agent's
|
|
138
|
+
// cwd-at-startup. We match against `session.meta.cwd` to find the
|
|
139
|
+
// workspace_id, then pick the most recent session in that workspace whose
|
|
140
|
+
// `created_at >= session.meta.createdAt` (allowing a small clock-skew
|
|
141
|
+
// epsilon). Returns null gracefully if better-sqlite3 isn't loadable, the
|
|
142
|
+
// DB doesn't open, the workspace isn't found, or no session matches.
|
|
143
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
const _GROK_RESOLVE_EPSILON_MS = 5_000;
|
|
146
|
+
|
|
147
|
+
async function resolveTranscriptPath(session) {
|
|
148
|
+
if (!session || !session.meta || !session.meta.cwd) return null;
|
|
149
|
+
const fs = require('fs');
|
|
150
|
+
const path = require('path');
|
|
151
|
+
const os = require('os');
|
|
152
|
+
let Database;
|
|
153
|
+
try { Database = require('better-sqlite3'); }
|
|
154
|
+
catch (_) { return null; } // dep missing → no-op
|
|
155
|
+
const dbPath = path.join(os.homedir(), '.grok', 'grok.db');
|
|
156
|
+
if (!fs.existsSync(dbPath)) return null;
|
|
157
|
+
let db;
|
|
158
|
+
try {
|
|
159
|
+
db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
|
160
|
+
} catch (_) { return null; }
|
|
161
|
+
try {
|
|
162
|
+
const ws = db.prepare(
|
|
163
|
+
'SELECT id FROM workspaces WHERE canonical_path = ? LIMIT 1'
|
|
164
|
+
).get(session.meta.cwd);
|
|
165
|
+
if (!ws) return null;
|
|
166
|
+
const createdAtMs = session.meta.createdAt
|
|
167
|
+
? Date.parse(session.meta.createdAt) - _GROK_RESOLVE_EPSILON_MS
|
|
168
|
+
: 0;
|
|
169
|
+
const grokSession = db.prepare(
|
|
170
|
+
'SELECT id, created_at FROM sessions WHERE workspace_id = ? ORDER BY created_at DESC LIMIT 1'
|
|
171
|
+
).get(ws.id);
|
|
172
|
+
if (!grokSession) return null;
|
|
173
|
+
if (createdAtMs && Date.parse(grokSession.created_at) < createdAtMs) {
|
|
174
|
+
return null; // most recent grok session predates this panel — no match
|
|
175
|
+
}
|
|
176
|
+
const rows = db.prepare(
|
|
177
|
+
'SELECT message_json FROM messages WHERE session_id = ? ORDER BY seq ASC'
|
|
178
|
+
).all(grokSession.id);
|
|
179
|
+
if (!rows || rows.length === 0) return null;
|
|
180
|
+
const envelope = [];
|
|
181
|
+
for (const row of rows) {
|
|
182
|
+
let parsed;
|
|
183
|
+
try { parsed = JSON.parse(row.message_json); } catch (_) { continue; }
|
|
184
|
+
if (!parsed || typeof parsed !== 'object') continue;
|
|
185
|
+
const role = parsed.role;
|
|
186
|
+
if (role !== 'user' && role !== 'assistant') continue;
|
|
187
|
+
envelope.push({ role, content: parsed.content });
|
|
188
|
+
}
|
|
189
|
+
if (envelope.length === 0) return null;
|
|
190
|
+
const safeId = String(session.id || `unknown-${Date.now()}`).replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
191
|
+
const tmpfile = path.join(os.tmpdir(), `termdeck-grok-${safeId}.json`);
|
|
192
|
+
fs.writeFileSync(tmpfile, JSON.stringify(envelope), 'utf8');
|
|
193
|
+
return tmpfile;
|
|
194
|
+
} catch (_) {
|
|
195
|
+
return null;
|
|
196
|
+
} finally {
|
|
197
|
+
try { db.close(); } catch (_) { /* fail-soft */ }
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
124
201
|
// ──────────────────────────────────────────────────────────────────────────
|
|
125
202
|
// parseTranscript — Grok stores messages in SQLite (~/.grok/grok.db), not
|
|
126
203
|
// in a JSONL file. The adapter contract is `(raw: string) => Memory[]`, so
|
|
@@ -360,6 +437,8 @@ function _mergeMnestraIntoGrokSettings(rawText, { secrets } = {}) {
|
|
|
360
437
|
const grokAdapter = {
|
|
361
438
|
name: 'grok',
|
|
362
439
|
sessionType: 'grok',
|
|
440
|
+
// Sprint 50 T3 — see claude.js for rationale.
|
|
441
|
+
displayName: 'Grok CLI',
|
|
363
442
|
matches: (cmd) => typeof cmd === 'string' && /(?:^|\s|\/)grok(?:\b|$)/i.test(cmd),
|
|
364
443
|
spawn: {
|
|
365
444
|
binary: 'grok',
|
|
@@ -382,6 +461,9 @@ const grokAdapter = {
|
|
|
382
461
|
},
|
|
383
462
|
statusFor,
|
|
384
463
|
parseTranscript,
|
|
464
|
+
// Sprint 50 T1 — 10th adapter field. SQLite extraction → tempfile JSON
|
|
465
|
+
// envelope (see header above for rationale + workspace mapping).
|
|
466
|
+
resolveTranscriptPath,
|
|
385
467
|
bootPromptTemplate,
|
|
386
468
|
costBand: 'subscription',
|
|
387
469
|
// Sprint 47 T3 — Grok's Bun+OpenTUI input box hasn't been empirically
|
|
@@ -9,6 +9,7 @@ const path = require('path');
|
|
|
9
9
|
const os = require('os');
|
|
10
10
|
const fs = require('fs');
|
|
11
11
|
const dns = require('dns');
|
|
12
|
+
const { spawn: spawnChild } = require('child_process');
|
|
12
13
|
const { v4: uuidv4 } = require('uuid');
|
|
13
14
|
const { createCachedLookup, createFailureLogger } = require('./rumen-pool-resilience');
|
|
14
15
|
|
|
@@ -128,6 +129,97 @@ function readTermdeckSecretsForPty() {
|
|
|
128
129
|
// Test hook — clear the cache between tests that mutate the on-disk file.
|
|
129
130
|
function _resetTermdeckSecretsCache() { _termdeckSecretsCache = null; }
|
|
130
131
|
|
|
132
|
+
// Sprint 50 T1 — Per-agent SessionEnd hook trigger.
|
|
133
|
+
//
|
|
134
|
+
// `_spawnSessionEndHookImpl` is the production spawn path; tests swap it
|
|
135
|
+
// out via `_setSpawnSessionEndHookImplForTesting` to capture the
|
|
136
|
+
// payload + arguments deterministically. The reason this indirection
|
|
137
|
+
// exists rather than mocking `child_process.spawn`: `node:test` doesn't
|
|
138
|
+
// run detached + stdio:['pipe','ignore','ignore'] children inside the
|
|
139
|
+
// test runner (verified — direct spawn with the same options fails to
|
|
140
|
+
// even invoke the script's first line). Mocking `child_process` would
|
|
141
|
+
// require module-level mocking which the runner doesn't support out of
|
|
142
|
+
// the box. A single-function injection keeps the surface tiny.
|
|
143
|
+
function _defaultSpawnSessionEndHookImpl(hookPath, payload, env) {
|
|
144
|
+
const child = spawnChild('node', [hookPath], {
|
|
145
|
+
stdio: ['pipe', 'ignore', 'ignore'],
|
|
146
|
+
detached: true,
|
|
147
|
+
env,
|
|
148
|
+
});
|
|
149
|
+
child.on('error', (err) => {
|
|
150
|
+
console.error('[onPanelClose] hook spawn error:', err && err.message ? err.message : err);
|
|
151
|
+
});
|
|
152
|
+
try {
|
|
153
|
+
child.stdin.write(JSON.stringify(payload));
|
|
154
|
+
child.stdin.end();
|
|
155
|
+
} catch (err) {
|
|
156
|
+
console.error('[onPanelClose] hook stdin write failed:', err && err.message ? err.message : err);
|
|
157
|
+
}
|
|
158
|
+
child.unref();
|
|
159
|
+
return child;
|
|
160
|
+
}
|
|
161
|
+
let _spawnSessionEndHookImpl = _defaultSpawnSessionEndHookImpl;
|
|
162
|
+
function _setSpawnSessionEndHookImplForTesting(fn) {
|
|
163
|
+
_spawnSessionEndHookImpl = typeof fn === 'function' ? fn : _defaultSpawnSessionEndHookImpl;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Fires when a panel's PTY exits. Routes through the adapter registry's
|
|
167
|
+
// new `resolveTranscriptPath` field (10th adapter field, Sprint 50) and
|
|
168
|
+
// invokes the bundled `~/.claude/hooks/memory-session-end.js` with the
|
|
169
|
+
// right payload so Codex / Gemini / Grok panels write a `session_summary`
|
|
170
|
+
// row the same way Claude Code already does.
|
|
171
|
+
//
|
|
172
|
+
// Skip rules (in order):
|
|
173
|
+
// 1. Claude — its own SessionEnd hook (registered in
|
|
174
|
+
// ~/.claude/settings.json) ingests Claude rows. Double-firing here
|
|
175
|
+
// would either insert two rows per session or race the Claude hook.
|
|
176
|
+
// 2. Adapters without `resolveTranscriptPath` — older adapters or types
|
|
177
|
+
// not in the registry (shell, python-server, one-shot). No-op.
|
|
178
|
+
// 3. `resolveTranscriptPath` returns null — adapter declares no
|
|
179
|
+
// transcript exists for this session (panel never sent a turn).
|
|
180
|
+
// 4. ~/.claude/hooks/memory-session-end.js missing — user hasn't
|
|
181
|
+
// installed the TermDeck stack hook. No-op.
|
|
182
|
+
//
|
|
183
|
+
// Fail-soft contract: any error logs to stderr and exits cleanly. Never
|
|
184
|
+
// blocks panel teardown — the spawn is fire-and-forget (detached + unref).
|
|
185
|
+
//
|
|
186
|
+
// `source_agent` is included in the payload (T2 consumes it via the new
|
|
187
|
+
// `memory_items.source_agent` column). T1 just passes the value; if T2
|
|
188
|
+
// hasn't migrated the column yet at the moment of first fire, Supabase
|
|
189
|
+
// rejects the row and the hook logs `supabase-insert-failed: HTTP 4xx`.
|
|
190
|
+
async function onPanelClose(session) {
|
|
191
|
+
try {
|
|
192
|
+
if (!session || !session.meta) return;
|
|
193
|
+
const adapter = AGENT_ADAPTERS[session.meta.type]
|
|
194
|
+
|| Object.values(AGENT_ADAPTERS).find((a) => a.sessionType === session.meta.type);
|
|
195
|
+
if (!adapter) return;
|
|
196
|
+
if (adapter.sessionType === 'claude-code') return;
|
|
197
|
+
if (typeof adapter.resolveTranscriptPath !== 'function') return;
|
|
198
|
+
|
|
199
|
+
const transcriptPath = await adapter.resolveTranscriptPath(session);
|
|
200
|
+
if (!transcriptPath) return;
|
|
201
|
+
|
|
202
|
+
const hookPath = path.join(os.homedir(), '.claude', 'hooks', 'memory-session-end.js');
|
|
203
|
+
if (!fs.existsSync(hookPath)) return;
|
|
204
|
+
|
|
205
|
+
const payload = {
|
|
206
|
+
transcript_path: transcriptPath,
|
|
207
|
+
cwd: session.meta.cwd,
|
|
208
|
+
session_id: session.id,
|
|
209
|
+
sessionType: adapter.sessionType,
|
|
210
|
+
// Sprint 50 — T2 consumes this via the new memory_items.source_agent column.
|
|
211
|
+
source_agent: adapter.name,
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
_spawnSessionEndHookImpl(hookPath, payload, {
|
|
215
|
+
...process.env,
|
|
216
|
+
...readTermdeckSecretsForPty(),
|
|
217
|
+
});
|
|
218
|
+
} catch (err) {
|
|
219
|
+
console.error('[onPanelClose] error:', err && err.message ? err.message : err);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
131
223
|
// Sprint 37 T3 — lazy resolution of T2's CLI modules. The orchestration-preview
|
|
132
224
|
// helper is decoupled from T2's templates.js / init-project.js; we resolve
|
|
133
225
|
// them here and pass them into the helper. If a module is missing (e.g.
|
|
@@ -926,6 +1018,15 @@ function createServer(config) {
|
|
|
926
1018
|
|
|
927
1019
|
// Fire-and-forget session log (T2.5)
|
|
928
1020
|
writeSessionLog({ session, config, db, getSessionHistory });
|
|
1021
|
+
|
|
1022
|
+
// Sprint 50 T1 — fire the bundled SessionEnd hook for non-Claude
|
|
1023
|
+
// panels so Codex / Gemini / Grok /exits write to Mnestra the way
|
|
1024
|
+
// Claude Code already does. onPanelClose handles dispatch +
|
|
1025
|
+
// skip-claude + skip-when-no-transcript. Fire-and-forget; any
|
|
1026
|
+
// error logs and never blocks teardown.
|
|
1027
|
+
onPanelClose(session).catch((err) => {
|
|
1028
|
+
console.error('[onPanelClose] async error:', err && err.message ? err.message : err);
|
|
1029
|
+
});
|
|
929
1030
|
});
|
|
930
1031
|
|
|
931
1032
|
// Wire command logging to SQLite + RAG
|
|
@@ -1324,6 +1425,9 @@ function createServer(config) {
|
|
|
1324
1425
|
// • binary — canonical command name; client matches `^binary\b` (i)
|
|
1325
1426
|
// • costBand — 'free' | 'pay-per-token' | 'subscription' (Sprint 46
|
|
1326
1427
|
// surfaces this in PLANNING.md cost annotations)
|
|
1428
|
+
// • displayName — Sprint 50 T3: human-readable label for launcher buttons
|
|
1429
|
+
// and panel headers. Backwards-compat: existing clients
|
|
1430
|
+
// that ignore the field continue to work unchanged.
|
|
1327
1431
|
// Functions / RegExps are NOT serialized — match logic lives client-side
|
|
1328
1432
|
// and uses the binary as the prefix anchor. Adapter-specific shorthand
|
|
1329
1433
|
// (e.g. `cc` → `claude`) is normalized in app.js before this lookup.
|
|
@@ -1333,6 +1437,29 @@ function createServer(config) {
|
|
|
1333
1437
|
sessionType: a.sessionType,
|
|
1334
1438
|
binary: a.spawn && a.spawn.binary,
|
|
1335
1439
|
costBand: a.costBand,
|
|
1440
|
+
displayName: a.displayName || a.name,
|
|
1441
|
+
}));
|
|
1442
|
+
res.json(list);
|
|
1443
|
+
});
|
|
1444
|
+
|
|
1445
|
+
// GET /api/agents - Sprint 50 T3: richer adapter projection used by the
|
|
1446
|
+
// dashboard launcher to render one button per registered agent and by the
|
|
1447
|
+
// mixed-agent dogfood inject script to discover available agents. Adds
|
|
1448
|
+
// the full spawn descriptor (binary + defaultArgs) so callers don't need
|
|
1449
|
+
// to re-derive it from the binary alone. Coexists with /api/agent-adapters
|
|
1450
|
+
// (kept stable for the launcher-resolver client contract).
|
|
1451
|
+
app.get('/api/agents', (req, res) => {
|
|
1452
|
+
const list = Object.values(AGENT_ADAPTERS).map((a) => ({
|
|
1453
|
+
name: a.name,
|
|
1454
|
+
sessionType: a.sessionType,
|
|
1455
|
+
displayName: a.displayName || a.name,
|
|
1456
|
+
spawn: {
|
|
1457
|
+
binary: (a.spawn && a.spawn.binary) || a.name,
|
|
1458
|
+
defaultArgs: (a.spawn && Array.isArray(a.spawn.defaultArgs))
|
|
1459
|
+
? a.spawn.defaultArgs.slice()
|
|
1460
|
+
: [],
|
|
1461
|
+
},
|
|
1462
|
+
costBand: a.costBand,
|
|
1336
1463
|
}));
|
|
1337
1464
|
res.json(list);
|
|
1338
1465
|
});
|
|
@@ -2228,4 +2355,9 @@ module.exports = {
|
|
|
2228
2355
|
// Sprint 48 T4 — exported for unit testing the secrets.env → PTY env merge.
|
|
2229
2356
|
readTermdeckSecretsForPty,
|
|
2230
2357
|
_resetTermdeckSecretsCache,
|
|
2358
|
+
// Sprint 50 T1 — exported for unit testing the per-agent SessionEnd
|
|
2359
|
+
// hook trigger (skip-claude, no-transcript, no-hook-installed,
|
|
2360
|
+
// payload shape, fire-and-forget).
|
|
2361
|
+
onPanelClose,
|
|
2362
|
+
_setSpawnSessionEndHookImplForTesting,
|
|
2231
2363
|
};
|