@bakapiano/ccsm 0.3.0 → 0.4.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/CLAUDE.md +4 -1
- package/lib/focus.js +58 -0
- package/lib/sessions.js +113 -0
- package/package.json +1 -1
- package/public/app.js +49 -1
- package/public/index.html +22 -0
- package/server.js +17 -3
package/CLAUDE.md
CHANGED
|
@@ -88,7 +88,8 @@ The alternative ("one repo per workspace") was explicitly rejected — don't ref
|
|
|
88
88
|
| POST | `/api/sessions/new` | body `{repos, workspace?, launch?}` — picks/creates ws, clones missing repos, launches wt |
|
|
89
89
|
| POST | `/api/sessions/finder` | opens a wt with `claude` in `D:\ccsm` and the `finderPrompt` as opening message |
|
|
90
90
|
| POST | `/api/sessions/:id/resume` | body `{cwd}` — launches `wt -d <cwd> claude --resume <id>` |
|
|
91
|
-
|
|
|
91
|
+
| GET | `/api/sessions/recent` | recently-used sessions discovered from `~/.claude/projects/*/*.jsonl` mtimes, excluding currently-live ids |
|
|
92
|
+
| POST | `/api/sessions/:id/focus` | matches a wt window by title (cleaned of leading status glyphs) against the session's ai-title; falls back to PID-parent walk if no unique match |
|
|
92
93
|
| GET | `/api/terminals` | enumerate built-in terminal kinds + their process names |
|
|
93
94
|
| GET | `/api/health` | sanity ping |
|
|
94
95
|
|
|
@@ -112,6 +113,8 @@ ccsm uses variant 1 and always `path.resolve()`s the cwd first — defends again
|
|
|
112
113
|
|
|
113
114
|
**Don't test wt launching via `node -e "..."` inside `bash -c`.** Backslashes get eaten by shell quoting and the JS string ends up malformed. Write a `.js` file and `node file.js` instead.
|
|
114
115
|
|
|
116
|
+
**focusBySession — title-based wt window matching.** Walking up the claude.exe PID parent chain and taking `MainWindowHandle` of the wt process *always* returns the same canonical window in modern multi-window single-process wt — clicking different sessions all focused the same window. `focusBySession` instead lists all visible wt windows (`EnumWindows` filtered by process name), strips the leading wt status glyph (`✳ `, `⠐ `, `⠠ ` …) and compares to the session's ai-title. Falls back to title-substring, then cwd-basename, then the old PID-parent walk when no unique match — at least the user sees *some* wt window even if it's the wrong tab. Caveat: for sessions sitting in an inactive tab of a multi-tab wt window, the window title shows the *active* tab's title — so we can't find them by title alone and the fallback is wrong-tab.
|
|
117
|
+
|
|
115
118
|
**Focus helper (`lib/focus.js`).** One `powershell.exe -EncodedCommand <base64>` invocation per call, dispatched by `CCSM_FOCUS_MODE` env var into modes `list` (enumerate visible top-level windows owned by a process name), `focus-hwnd` (activate a specific HWND), `focus-pid` (walk parent chain to MainWindowHandle then activate). Encoded as UTF-16-LE-base64 — passing the C# block via stdin to `-Command -` silently produces no output, so we don't. Three gotchas baked in:
|
|
116
119
|
1. C# `out _` discard breaks PowerShell 5.1's bundled C# compiler — use a named `uint dummy`.
|
|
117
120
|
2. Windows blocks background processes from `SetForegroundWindow` — synthesize an Alt-key down/up via `keybd_event 0x12` first ("Alt-key trick") to qualify our process. Without it, `activated` returns false even though the window is found.
|
package/lib/focus.js
CHANGED
|
@@ -192,6 +192,63 @@ async function focusByPid(pid) {
|
|
|
192
192
|
return await runPsHelper('focus-pid', n);
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
+
// Strip the leading wt status glyph + whitespace ("✳ ", "⠐ ", "⠠ " etc) so
|
|
196
|
+
// the title compares cleanly. wt prefixes the tab title with a Braille-style
|
|
197
|
+
// progress glyph or a sparkle/asterisk depending on activity.
|
|
198
|
+
function cleanWtTitle(t) {
|
|
199
|
+
return String(t || '').replace(/^[^\w一-鿿]+/, '').trim();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Best-effort: focus the wt window that belongs to a specific session.
|
|
203
|
+
// Modern wt is single-process multi-window, so walking from claude.exe PID
|
|
204
|
+
// up to wt.exe + taking MainWindowHandle always returns the same canonical
|
|
205
|
+
// window regardless of which tab the session is actually in. Instead we
|
|
206
|
+
// list all wt windows and match by tab title against the session's AI
|
|
207
|
+
// title / cwd basename.
|
|
208
|
+
async function focusBySession({ pid, sessionId, title, cwd }) {
|
|
209
|
+
const procName = 'WindowsTerminal.exe';
|
|
210
|
+
const cands = await listWindowsOf(procName);
|
|
211
|
+
|
|
212
|
+
const cleanedTitle = title ? cleanWtTitle(title) : '';
|
|
213
|
+
const cwdBase = cwd ? require('node:path').basename(cwd) : '';
|
|
214
|
+
|
|
215
|
+
// 1. Exact match on cleaned ai-title
|
|
216
|
+
if (cleanedTitle) {
|
|
217
|
+
const exact = cands.filter((w) => cleanWtTitle(w.title) === cleanedTitle);
|
|
218
|
+
if (exact.length === 1) {
|
|
219
|
+
const r = await focusByHwnd(exact[0].hwnd);
|
|
220
|
+
return { ...r, matchedBy: 'title-exact', hwnd: exact[0].hwnd, windowTitle: exact[0].title };
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// 2. Title substring match (some shells append " - <cwd>" etc)
|
|
225
|
+
if (cleanedTitle) {
|
|
226
|
+
const subs = cands.filter((w) => w.title.includes(cleanedTitle));
|
|
227
|
+
if (subs.length === 1) {
|
|
228
|
+
const r = await focusByHwnd(subs[0].hwnd);
|
|
229
|
+
return { ...r, matchedBy: 'title-substring', hwnd: subs[0].hwnd, windowTitle: subs[0].title };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// 3. Match by cwd basename (workspace name shows up in title for fresh launches)
|
|
234
|
+
if (cwdBase) {
|
|
235
|
+
const byCwd = cands.filter((w) => w.title.includes(cwdBase));
|
|
236
|
+
if (byCwd.length === 1) {
|
|
237
|
+
const r = await focusByHwnd(byCwd[0].hwnd);
|
|
238
|
+
return { ...r, matchedBy: 'cwd-basename', hwnd: byCwd[0].hwnd, windowTitle: byCwd[0].title };
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// 4. Fall back to PID parent-chain walk (returns the wt process's
|
|
243
|
+
// canonical MainWindowHandle — may be the wrong window when wt is
|
|
244
|
+
// multi-window single-process, but better than nothing).
|
|
245
|
+
if (pid) {
|
|
246
|
+
const r = await focusByPid(pid);
|
|
247
|
+
return { ...r, matchedBy: 'pid-fallback', ambiguous: true };
|
|
248
|
+
}
|
|
249
|
+
return { ok: false, error: 'no match by title/cwd and no pid given', matchedBy: 'none' };
|
|
250
|
+
}
|
|
251
|
+
|
|
195
252
|
async function focusByHwnd(hwnd) {
|
|
196
253
|
return await runPsHelper('focus-hwnd', hwnd);
|
|
197
254
|
}
|
|
@@ -228,6 +285,7 @@ async function focusNewlyOpenedHwnd(beforeHwnds, processName, opts = {}) {
|
|
|
228
285
|
|
|
229
286
|
module.exports = {
|
|
230
287
|
focusByPid,
|
|
288
|
+
focusBySession,
|
|
231
289
|
focusByHwnd,
|
|
232
290
|
listWindowsOf,
|
|
233
291
|
snapshotWindowsOf,
|
package/lib/sessions.js
CHANGED
|
@@ -110,8 +110,121 @@ async function listSessions() {
|
|
|
110
110
|
.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
// Pull cwd/title/firstTimestamp from a jsonl by reading the head (cwd lives in
|
|
114
|
+
// any user/assistant/attachment line) and tailing for the last ai-title.
|
|
115
|
+
async function readJsonlMetadata(jsonlPath) {
|
|
116
|
+
let fd;
|
|
117
|
+
try {
|
|
118
|
+
fd = await fs.open(jsonlPath, 'r');
|
|
119
|
+
const stat = await fd.stat();
|
|
120
|
+
if (stat.size === 0) return { size: 0 };
|
|
121
|
+
|
|
122
|
+
const HEAD = Math.min(stat.size, 128 * 1024);
|
|
123
|
+
const headBuf = Buffer.alloc(HEAD);
|
|
124
|
+
await fd.read(headBuf, 0, HEAD, 0);
|
|
125
|
+
const headText = headBuf.toString('utf8');
|
|
126
|
+
|
|
127
|
+
let cwd = null;
|
|
128
|
+
let gitBranch = null;
|
|
129
|
+
let firstTimestamp = null;
|
|
130
|
+
for (const line of headText.split('\n')) {
|
|
131
|
+
if (!line) continue;
|
|
132
|
+
if (cwd && firstTimestamp) break;
|
|
133
|
+
if (!line.includes('"cwd"') && !line.includes('"timestamp"')) continue;
|
|
134
|
+
try {
|
|
135
|
+
const obj = JSON.parse(line);
|
|
136
|
+
if (obj.cwd && !cwd) cwd = obj.cwd;
|
|
137
|
+
if (obj.gitBranch && !gitBranch) gitBranch = obj.gitBranch;
|
|
138
|
+
if (obj.timestamp && !firstTimestamp) firstTimestamp = obj.timestamp;
|
|
139
|
+
} catch {}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const TAIL = Math.min(stat.size, 512 * 1024);
|
|
143
|
+
const tailBuf = Buffer.alloc(TAIL);
|
|
144
|
+
await fd.read(tailBuf, 0, TAIL, Math.max(0, stat.size - TAIL));
|
|
145
|
+
const tailText = tailBuf.toString('utf8');
|
|
146
|
+
let title = null;
|
|
147
|
+
const tailLines = tailText.split('\n');
|
|
148
|
+
for (let i = tailLines.length - 1; i >= 0; i--) {
|
|
149
|
+
if (!tailLines[i].includes('"type":"ai-title"')) continue;
|
|
150
|
+
try {
|
|
151
|
+
const obj = JSON.parse(tailLines[i]);
|
|
152
|
+
if (obj.aiTitle) { title = obj.aiTitle; break; }
|
|
153
|
+
} catch {}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return { cwd, gitBranch, firstTimestamp, title, size: stat.size };
|
|
157
|
+
} catch {
|
|
158
|
+
return {};
|
|
159
|
+
} finally {
|
|
160
|
+
if (fd) await fd.close().catch(() => {});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// List every recently-used Claude session by enumerating ~/.claude/projects/
|
|
165
|
+
// *.jsonl files. Excludes session ids that are currently live (caller passes
|
|
166
|
+
// `excludeIds` from listSessions()). Sorted by file mtime desc.
|
|
167
|
+
async function listRecentSessions({ limit = 50, excludeIds = null } = {}) {
|
|
168
|
+
let projectDirs;
|
|
169
|
+
try {
|
|
170
|
+
projectDirs = await fs.readdir(PROJECTS_DIR);
|
|
171
|
+
} catch {
|
|
172
|
+
return [];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const candidates = [];
|
|
176
|
+
for (const slugDir of projectDirs) {
|
|
177
|
+
const dirPath = path.join(PROJECTS_DIR, slugDir);
|
|
178
|
+
let entries;
|
|
179
|
+
try {
|
|
180
|
+
entries = await fs.readdir(dirPath);
|
|
181
|
+
} catch {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
for (const file of entries) {
|
|
185
|
+
if (!file.endsWith('.jsonl')) continue;
|
|
186
|
+
const sessionId = file.slice(0, -'.jsonl'.length);
|
|
187
|
+
if (excludeIds && excludeIds.has(sessionId)) continue;
|
|
188
|
+
const fullPath = path.join(dirPath, file);
|
|
189
|
+
try {
|
|
190
|
+
const stat = await fs.stat(fullPath);
|
|
191
|
+
if (stat.size === 0) continue;
|
|
192
|
+
candidates.push({
|
|
193
|
+
sessionId,
|
|
194
|
+
slug: slugDir,
|
|
195
|
+
jsonlPath: fullPath,
|
|
196
|
+
mtime: stat.mtimeMs,
|
|
197
|
+
});
|
|
198
|
+
} catch {}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
candidates.sort((a, b) => b.mtime - a.mtime);
|
|
203
|
+
const top = candidates.slice(0, limit);
|
|
204
|
+
|
|
205
|
+
const results = await Promise.all(
|
|
206
|
+
top.map(async (c) => {
|
|
207
|
+
const meta = await readJsonlMetadata(c.jsonlPath);
|
|
208
|
+
const firstTs = meta.firstTimestamp ? Date.parse(meta.firstTimestamp) : null;
|
|
209
|
+
return {
|
|
210
|
+
sessionId: c.sessionId,
|
|
211
|
+
cwd: meta.cwd || null,
|
|
212
|
+
title: meta.title || null,
|
|
213
|
+
gitBranch: meta.gitBranch || null,
|
|
214
|
+
updatedAt: c.mtime,
|
|
215
|
+
startedAt: Number.isFinite(firstTs) ? firstTs : null,
|
|
216
|
+
jsonlPath: c.jsonlPath,
|
|
217
|
+
};
|
|
218
|
+
})
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
// Drop entries with no cwd — can't resume without one
|
|
222
|
+
return results.filter((r) => r.cwd);
|
|
223
|
+
}
|
|
224
|
+
|
|
113
225
|
module.exports = {
|
|
114
226
|
listSessions,
|
|
227
|
+
listRecentSessions,
|
|
115
228
|
projectSlugForCwd,
|
|
116
229
|
getLiveClaudePids,
|
|
117
230
|
SESSIONS_DIR,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bakapiano/ccsm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Claude Code Session Manager — Windows web UI to manage many concurrent claude sessions: live list, snapshot/restore, focus existing window, new session in an isolated workspace with repo clones",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "server.js",
|
package/public/app.js
CHANGED
|
@@ -83,6 +83,29 @@ function renderSessions() {
|
|
|
83
83
|
state.sessions.length ? `${state.sessions.length} live · last refresh ${new Date().toLocaleTimeString()}` : 'no live sessions';
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
function renderRecent() {
|
|
87
|
+
const tb = $('#recentTable tbody');
|
|
88
|
+
tb.innerHTML = '';
|
|
89
|
+
const recent = state.recent || [];
|
|
90
|
+
for (const s of recent) {
|
|
91
|
+
const tr = document.createElement('tr');
|
|
92
|
+
tr.innerHTML = `
|
|
93
|
+
<td><div class="ellipsis" title="${escapeHtml(s.title || '')}">${escapeHtml(s.title || '(no title)')}</div>
|
|
94
|
+
<div class="mono small" title="${escapeHtml(s.sessionId)}">${escapeHtml(s.sessionId.slice(0,8))}…</div></td>
|
|
95
|
+
<td><div class="ellipsis mono" title="${escapeHtml(s.cwd || '')}">${escapeHtml(s.cwd || '')}</div></td>
|
|
96
|
+
<td class="mono small">${escapeHtml(s.gitBranch || '')}</td>
|
|
97
|
+
<td title="${escapeHtml(fmtTime(s.updatedAt))}">${escapeHtml(fmtAgo(s.updatedAt))}</td>
|
|
98
|
+
<td title="${escapeHtml(fmtTime(s.startedAt))}">${escapeHtml(fmtAgo(s.startedAt))}</td>
|
|
99
|
+
<td style="text-align:right;">
|
|
100
|
+
<button class="btn small btn-primary" data-continue="${escapeHtml(s.sessionId)}" data-cwd="${escapeHtml(s.cwd)}" title="open a new wt window with claude --resume">continue</button>
|
|
101
|
+
</td>
|
|
102
|
+
`;
|
|
103
|
+
tb.appendChild(tr);
|
|
104
|
+
}
|
|
105
|
+
$('#recentMeta').textContent =
|
|
106
|
+
recent.length ? `${recent.length} recent · last refresh ${new Date().toLocaleTimeString()}` : 'no recent sessions';
|
|
107
|
+
}
|
|
108
|
+
|
|
86
109
|
// ---- snapshot render ----
|
|
87
110
|
|
|
88
111
|
function renderSnapshot() {
|
|
@@ -221,6 +244,12 @@ async function loadSessions() {
|
|
|
221
244
|
renderSessions();
|
|
222
245
|
}
|
|
223
246
|
|
|
247
|
+
async function loadRecent() {
|
|
248
|
+
const r = await api('GET', '/api/sessions/recent?limit=50');
|
|
249
|
+
state.recent = r.recent;
|
|
250
|
+
renderRecent();
|
|
251
|
+
}
|
|
252
|
+
|
|
224
253
|
async function loadConfig() {
|
|
225
254
|
const [cfg, terminals] = await Promise.all([
|
|
226
255
|
api('GET', '/api/config'),
|
|
@@ -249,7 +278,7 @@ async function loadWorkspaces() {
|
|
|
249
278
|
}
|
|
250
279
|
|
|
251
280
|
async function refreshAll() {
|
|
252
|
-
await Promise.all([loadSessions(), loadSnapshot(), loadWorkspaces()]);
|
|
281
|
+
await Promise.all([loadSessions(), loadRecent(), loadSnapshot(), loadWorkspaces()]);
|
|
253
282
|
}
|
|
254
283
|
|
|
255
284
|
// ---- event wiring ----
|
|
@@ -298,6 +327,24 @@ function wireUp() {
|
|
|
298
327
|
}
|
|
299
328
|
});
|
|
300
329
|
|
|
330
|
+
$('#recentTable').addEventListener('click', async (ev) => {
|
|
331
|
+
const btn = ev.target.closest('button[data-continue]');
|
|
332
|
+
if (!btn) return;
|
|
333
|
+
const sessionId = btn.dataset.continue;
|
|
334
|
+
const cwd = btn.dataset.cwd;
|
|
335
|
+
btn.disabled = true;
|
|
336
|
+
try {
|
|
337
|
+
await api('POST', `/api/sessions/${sessionId}/resume`, { cwd });
|
|
338
|
+
toast(`continuing ${sessionId.slice(0, 8)}…`);
|
|
339
|
+
setTimeout(() => loadSessions().catch(() => {}), 3000);
|
|
340
|
+
setTimeout(() => loadRecent().catch(() => {}), 4000);
|
|
341
|
+
} catch (e) {
|
|
342
|
+
toast(e.message, 'error');
|
|
343
|
+
} finally {
|
|
344
|
+
btn.disabled = false;
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
|
|
301
348
|
$('#finderBtn').onclick = async () => {
|
|
302
349
|
try {
|
|
303
350
|
await api('POST', '/api/sessions/finder');
|
|
@@ -408,6 +455,7 @@ function startAutoRefresh() {
|
|
|
408
455
|
stopAutoRefresh();
|
|
409
456
|
state.autoTimer = setInterval(() => {
|
|
410
457
|
loadSessions().catch(() => {});
|
|
458
|
+
loadRecent().catch(() => {});
|
|
411
459
|
loadSnapshot().catch(() => {});
|
|
412
460
|
}, 5000);
|
|
413
461
|
}
|
package/public/index.html
CHANGED
|
@@ -49,6 +49,28 @@
|
|
|
49
49
|
</div>
|
|
50
50
|
</section>
|
|
51
51
|
|
|
52
|
+
<section class="panel">
|
|
53
|
+
<header class="panel-header">
|
|
54
|
+
<h2>recently closed</h2>
|
|
55
|
+
<span id="recentMeta" class="muted"></span>
|
|
56
|
+
</header>
|
|
57
|
+
<div class="table-wrap">
|
|
58
|
+
<table id="recentTable" class="data-table">
|
|
59
|
+
<thead>
|
|
60
|
+
<tr>
|
|
61
|
+
<th>title</th>
|
|
62
|
+
<th>cwd</th>
|
|
63
|
+
<th>branch</th>
|
|
64
|
+
<th>updated</th>
|
|
65
|
+
<th>started</th>
|
|
66
|
+
<th></th>
|
|
67
|
+
</tr>
|
|
68
|
+
</thead>
|
|
69
|
+
<tbody></tbody>
|
|
70
|
+
</table>
|
|
71
|
+
</div>
|
|
72
|
+
</section>
|
|
73
|
+
|
|
52
74
|
<section class="panel">
|
|
53
75
|
<header class="panel-header">
|
|
54
76
|
<h2>snapshot</h2>
|
package/server.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
const path = require('node:path');
|
|
5
5
|
const express = require('express');
|
|
6
6
|
|
|
7
|
-
const { listSessions } = require('./lib/sessions');
|
|
7
|
+
const { listSessions, listRecentSessions } = require('./lib/sessions');
|
|
8
8
|
const { loadConfig, saveConfig, DATA_DIR } = require('./lib/config');
|
|
9
9
|
const {
|
|
10
10
|
saveSnapshot,
|
|
@@ -26,6 +26,7 @@ const {
|
|
|
26
26
|
} = require('./lib/launcher');
|
|
27
27
|
const {
|
|
28
28
|
focusByPid,
|
|
29
|
+
focusBySession,
|
|
29
30
|
snapshotWindowsOf,
|
|
30
31
|
focusNewlyOpenedHwnd,
|
|
31
32
|
} = require('./lib/focus');
|
|
@@ -61,6 +62,14 @@ app.get('/api/sessions', asyncH(async (_req, res) => {
|
|
|
61
62
|
res.json({ sessions, takenAt: Date.now() });
|
|
62
63
|
}));
|
|
63
64
|
|
|
65
|
+
app.get('/api/sessions/recent', asyncH(async (req, res) => {
|
|
66
|
+
const limit = Math.min(200, Number(req.query.limit) || 50);
|
|
67
|
+
const live = await listSessions();
|
|
68
|
+
const excludeIds = new Set(live.map((s) => s.sessionId));
|
|
69
|
+
const recent = await listRecentSessions({ limit, excludeIds });
|
|
70
|
+
res.json({ recent, takenAt: Date.now() });
|
|
71
|
+
}));
|
|
72
|
+
|
|
64
73
|
// ---- config ----
|
|
65
74
|
|
|
66
75
|
app.get('/api/config', asyncH(async (_req, res) => {
|
|
@@ -296,8 +305,13 @@ app.post('/api/sessions/:sessionId/focus', asyncH(async (req, res) => {
|
|
|
296
305
|
const sessions = await listSessions();
|
|
297
306
|
const s = sessions.find((x) => x.sessionId === sessionId);
|
|
298
307
|
if (!s) return res.status(404).json({ error: `session ${sessionId} not live` });
|
|
299
|
-
const result = await
|
|
300
|
-
|
|
308
|
+
const result = await focusBySession({
|
|
309
|
+
pid: s.pid,
|
|
310
|
+
sessionId: s.sessionId,
|
|
311
|
+
title: s.title,
|
|
312
|
+
cwd: s.cwd,
|
|
313
|
+
});
|
|
314
|
+
res.json({ session: { pid: s.pid, sessionId: s.sessionId, cwd: s.cwd, title: s.title }, ...result });
|
|
301
315
|
}));
|
|
302
316
|
|
|
303
317
|
// ---- terminal kinds ----
|