clideck 1.25.8 → 1.26.2

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/sessions.js CHANGED
@@ -29,7 +29,11 @@ function addBroadcastListener(fn) { broadcastListeners.push(fn); }
29
29
  function broadcast(msg) {
30
30
  const raw = JSON.stringify(msg);
31
31
  for (const c of clients) if (c.readyState === 1) c.send(raw);
32
- if (msg.type === 'session.status') plugins.notifyStatus(msg.id, msg.working);
32
+ if (msg.type === 'session.status') {
33
+ const s = sessions.get(msg.id);
34
+ if (s) s.working = !!msg.working;
35
+ plugins.notifyStatus(msg.id, msg.working, msg.source);
36
+ }
33
37
  for (const fn of broadcastListeners) try { fn(msg); } catch {}
34
38
  }
35
39
 
@@ -74,7 +78,7 @@ function spawnSession(id, cmd, parts, cwd, name, themeId, commandId, savedToken,
74
78
  const sessionIdRe = cmd.sessionIdPattern ? new RegExp(cmd.sessionIdPattern, 'i') : null;
75
79
  const bin = binName(cmd.command);
76
80
  const preset = PRESETS.find(p => binName(p.command) === bin);
77
- const session = { name, themeId, commandId, cwd, pty: term, chunks: [], chunksSize: 0, sessionToken: savedToken || null, projectId: projectId || null, presetId: preset?.presetId || 'shell' };
81
+ const session = { name, themeId, commandId, cwd, pty: term, chunks: [], chunksSize: 0, sessionToken: savedToken || null, projectId: projectId || null, presetId: preset?.presetId || 'shell', working: undefined };
78
82
  sessions.set(id, session);
79
83
 
80
84
  // Watch for telemetry — if config isn't set up, frontend will prompt
@@ -82,6 +86,18 @@ function spawnSession(id, cmd, parts, cwd, name, themeId, commandId, savedToken,
82
86
  if (preset?.bridge === 'opencode') opencodeBridge.watchSession(id, cwd);
83
87
 
84
88
  term.onData((data) => {
89
+ // Inject role prompt once after agent starts producing output
90
+ if (session.pendingRolePrompt && !session._rolePromptTimer) {
91
+ session._rolePromptTimer = setTimeout(() => {
92
+ if (session.pendingRolePrompt) {
93
+ term.write(session.pendingRolePrompt);
94
+ setTimeout(() => term.write('\r'), 150);
95
+ console.log(`Session ${id.slice(0, 8)}: injected role prompt`);
96
+ delete session.pendingRolePrompt;
97
+ delete session._rolePromptTimer;
98
+ }
99
+ }, 3000);
100
+ }
85
101
  session.chunks.push(data);
86
102
  session.chunksSize += data.length;
87
103
  while (session.chunksSize > MAX_BUFFER && session.chunks.length > 1) {
@@ -104,14 +120,30 @@ function spawnSession(id, cmd, parts, cwd, name, themeId, commandId, savedToken,
104
120
 
105
121
  term.onExit(() => {
106
122
  // Skip cleanup if this PTY was replaced by a restart
107
- if (sessions.get(id)?.pty !== term) return;
123
+ const s = sessions.get(id);
124
+ if (s?.pty !== term) return;
108
125
  activity.clear(id);
109
126
  telemetry.clear(id);
110
127
  opencodeBridge.clear(id);
111
- transcript.clear(id);
112
128
  plugins.clearStatus(id);
129
+ // If resumable and token captured, move to resumable list (keep transcript for search)
130
+ if (!s.ephemeral && cmd.canResume && cmd.resumeCommand && s.sessionToken) {
131
+ resumable.push({
132
+ id, name: s.name, commandId: s.commandId, presetId: s.presetId || 'shell', cwd: s.cwd,
133
+ themeId: s.themeId, sessionToken: s.sessionToken, projectId: s.projectId, muted: !!s.muted,
134
+ roleName: s.roleName || null,
135
+ lastPreview: s.lastPreview || '', lastActivityAt: s.lastActivityAt || null,
136
+ savedAt: new Date().toISOString(),
137
+ });
138
+ console.log(`Session ${id.slice(0, 8)}: moved to resumable on exit (token: ${s.sessionToken.slice(0, 12)}…)`);
139
+ } else {
140
+ transcript.clear(id);
141
+ }
113
142
  sessions.delete(id);
114
143
  broadcast({ type: 'closed', id });
144
+ if (!s.ephemeral && cmd.canResume && s.sessionToken) {
145
+ broadcast({ type: 'sessions.resumable', list: getResumable() });
146
+ }
115
147
  });
116
148
 
117
149
  return null;
@@ -137,6 +169,18 @@ function create(msg, ws, cfg) {
137
169
  return;
138
170
  }
139
171
 
172
+ // If a role was selected, store identity on session and queue prompt injection
173
+ if (msg.roleId) {
174
+ const role = (cfg.roles || []).find(r => r.id === msg.roleId);
175
+ if (role) {
176
+ const s = sessions.get(id);
177
+ if (s) {
178
+ s.roleName = role.name;
179
+ if (role.instructions) s.pendingRolePrompt = role.instructions;
180
+ }
181
+ }
182
+ }
183
+
140
184
  const createdPresetId = PRESETS.find(p => binName(p.command) === binName(cmd.command))?.presetId || 'shell';
141
185
  const installId = msg.installId || undefined;
142
186
  broadcast({ type: 'created', id, name, themeId, commandId: cmd.id, presetId: createdPresetId, projectId, installId });
@@ -149,6 +193,33 @@ function create(msg, ws, cfg) {
149
193
  }
150
194
  }
151
195
 
196
+ // --- Programmatic session creation (for plugins / internal use) ---
197
+
198
+ function createProgrammatic(opts, cfg) {
199
+ const id = crypto.randomUUID();
200
+ let cmd;
201
+ if (opts.presetId) cmd = cfg.commands.find(c => c.presetId === opts.presetId);
202
+ else if (opts.commandId) cmd = cfg.commands.find(c => c.id === opts.commandId);
203
+ if (!cmd) return { error: 'Command not found' };
204
+
205
+ const parts = parseCommand(cmd.command);
206
+ const cwd = resolveValidDir(opts.cwd || cmd.defaultPath || cfg.defaultPath);
207
+ const themeId = opts.themeId || cfg.defaultTheme || 'default';
208
+ const name = opts.name || cmd.label;
209
+ const projectId = opts.projectId || null;
210
+
211
+ const err = spawnSession(id, cmd, parts, cwd, name, themeId, cmd.id, null, projectId);
212
+ if (err) return { error: err.message };
213
+
214
+ const s = sessions.get(id);
215
+ if (s && opts.roleName) s.roleName = opts.roleName;
216
+ if (s && opts.ephemeral) s.ephemeral = true;
217
+
218
+ const presetId = PRESETS.find(p => binName(p.command) === binName(cmd.command))?.presetId || 'shell';
219
+ broadcast({ type: 'created', id, name, themeId, commandId: cmd.id, presetId, projectId });
220
+ return { id };
221
+ }
222
+
152
223
  // --- Resume a persisted session ---
153
224
 
154
225
  function resume(msg, ws, cfg) {
@@ -185,7 +256,11 @@ function resume(msg, ws, cfg) {
185
256
  return;
186
257
  }
187
258
 
188
- if (saved.muted) { const s = sessions.get(id); if (s) s.muted = true; }
259
+ const s = sessions.get(id);
260
+ if (s) {
261
+ if (saved.muted) s.muted = true;
262
+ if (saved.roleName) s.roleName = saved.roleName;
263
+ }
189
264
 
190
265
  // Remove from resumable list and notify all clients
191
266
  resumable = resumable.filter(s => s.id !== id);
@@ -202,6 +277,11 @@ function input(msg) {
202
277
  activity.trackIn(msg.id, data.length);
203
278
  transcript.trackInput(msg.id, data);
204
279
  sessions.get(msg.id)?.pty.write(data);
280
+ if (data === '\x1b') {
281
+ const s = sessions.get(msg.id);
282
+ console.log(`[esc] session=${msg.id.slice(0,8)} working=${s?.working}`);
283
+ if (s?.working) telemetry.startEscIdle(msg.id);
284
+ }
205
285
  }
206
286
  function resize(msg) { sessions.get(msg.id)?.pty.resize(msg.cols, msg.rows); }
207
287
 
@@ -280,6 +360,7 @@ function restart(msg, ws, cfg) {
280
360
  function list() {
281
361
  return [...sessions].map(([id, s]) => ({
282
362
  id, name: s.name, themeId: s.themeId, commandId: s.commandId, presetId: s.presetId || 'shell', projectId: s.projectId, muted: !!s.muted,
363
+ roleName: s.roleName || null,
283
364
  // Last preview text for sidebar display on reconnect
284
365
  lastPreview: s.lastPreview || '', lastActivityAt: s.lastActivityAt || null,
285
366
  menu: s._menuKey ? JSON.parse(s._menuKey) : undefined,
@@ -325,6 +406,7 @@ function saveSessions(cfg) {
325
406
  let skippedNoToken = 0;
326
407
  const live = [...sessions]
327
408
  .filter(([, s]) => {
409
+ if (s.ephemeral) return false;
328
410
  const cmd = cfg.commands.find(c => c.id === s.commandId);
329
411
  if (!cmd?.canResume || !cmd.resumeCommand) return false;
330
412
  // If resume needs a session ID, we must have captured one
@@ -337,6 +419,7 @@ function saveSessions(cfg) {
337
419
  .map(([id, s]) => ({
338
420
  id, name: s.name, commandId: s.commandId, presetId: s.presetId || 'shell', cwd: s.cwd,
339
421
  themeId: s.themeId, sessionToken: s.sessionToken, projectId: s.projectId, muted: !!s.muted,
422
+ roleName: s.roleName || null,
340
423
  lastPreview: s.lastPreview || '', lastActivityAt: s.lastActivityAt || null,
341
424
  savedAt: new Date().toISOString(),
342
425
  }));
@@ -387,7 +470,7 @@ function shutdown(cfg) {
387
470
 
388
471
  module.exports = {
389
472
  clients, broadcast, addBroadcastListener, getSessions: () => sessions,
390
- create, resume, restart, input, resize, rename, setTheme, setMute, setProject, setPreview, close,
473
+ create, createProgrammatic, resume, restart, input, resize, rename, setTheme, setMute, setProject, setPreview, close,
391
474
  list, getResumable, sendBuffers,
392
475
  loadSessions, startAutoSave, shutdown,
393
476
  };
@@ -5,7 +5,9 @@
5
5
  const ioActivity = require('./activity');
6
6
  const activity = new Map(); // sessionId → has received events
7
7
  const pendingSetup = new Map(); // sessionId → timer (waiting for first event)
8
- const pendingIdle = new Map(); // sessionId → timer (api_requestconfirm idle after output silence)
8
+ const pendingIdle = new Map(); // sessionId → timer (PTY silence → idle)
9
+ const escPendingIdle = new Map(); // sessionId → timer (Esc interrupt → confirm idle after output silence)
10
+ const escSuppressUntil = new Map(); // sessionId → ts (briefly ignore telemetry reassertions after Esc)
9
11
  let broadcastFn = null;
10
12
  let sessionsFn = null;
11
13
 
@@ -65,47 +67,22 @@ function handleLogs(req, res) {
65
67
  if (firstEvent) console.log(`Telemetry: first event from ${agent} (${resolvedId.slice(0, 8)})`);
66
68
 
67
69
  // Process each log record — capture session ID for resume
68
- let captured = false;
69
70
  for (const sl of rl.scopeLogs || []) {
70
71
  for (const lr of sl.logRecords || []) {
71
72
  const attrs = parseAttrs(lr.attributes);
72
-
73
73
  const eventName = attrs['event.name'];
74
+
74
75
  // Debug telemetry logs — uncomment as needed, do not delete
75
76
  // if (serviceName === 'claude-code' && eventName) console.log(`[telemetry:claude] ${eventName}`);
76
- // if (serviceName === 'codex_cli_rs' && eventName) console.log(`[telemetry:codex] ${eventName}`);
77
+ // if (serviceName === 'codex_cli_rs' && eventName) console.log(`[telemetry:codex] ${eventName} session=${resolvedId.slice(0,8)} working=${sessionsFn?.()?.get(resolvedId)?.working}`);
77
78
  // if (serviceName === 'gemini-cli' && eventName) console.log(`[telemetry:gemini] ${eventName}`);
78
79
 
79
- // Telemetry-based status
80
+ // Status: user_prompt → working + start PTY silence monitor for idle
80
81
  const startEvents = new Set(['user_prompt', 'gemini_cli.user_prompt', 'codex.user_prompt']);
81
82
  if (startEvents.has(eventName)) {
83
+ cancelPendingIdle(resolvedId);
82
84
  broadcastFn?.({ type: 'session.status', id: resolvedId, working: true, source: 'telemetry' });
83
- }
84
- // Claude: telemetry-only status. api_request → pending idle (confirm after 1s output silence, expire after 6s).
85
- if (serviceName === 'claude-code' && eventName) {
86
- if (eventName === 'api_request') {
87
- startPendingIdle(resolvedId);
88
- } else if (eventName !== 'user_prompt') {
89
- cancelPendingIdle(resolvedId);
90
- broadcastFn?.({ type: 'session.status', id: resolvedId, working: true, source: 'telemetry' });
91
- }
92
- }
93
- // Codex: telemetry-only status. codex.user_prompt/any event → working, codex.sse_event → idle.
94
- if (serviceName === 'codex_cli_rs' && eventName) {
95
- if (eventName === 'codex.sse_event') {
96
- broadcastFn?.({ type: 'session.status', id: resolvedId, working: false, source: 'telemetry' });
97
- } else if (eventName !== 'codex.user_prompt') {
98
- broadcastFn?.({ type: 'session.status', id: resolvedId, working: true, source: 'telemetry' });
99
- }
100
- }
101
- // Gemini: telemetry-only status. Whitelisted events → working, api_response (role=main) → idle.
102
- if (serviceName === 'gemini-cli' && eventName) {
103
- if (eventName === 'gemini_cli.api_response' && attrs['role'] === 'main') {
104
- broadcastFn?.({ type: 'session.status', id: resolvedId, working: false, source: 'telemetry' });
105
- } else if (eventName === 'gemini_cli.api_request' || eventName === 'gemini_cli.model_routing'
106
- || (eventName === 'gemini_cli.api_response' && attrs['role'] !== 'main')) {
107
- broadcastFn?.({ type: 'session.status', id: resolvedId, working: true, source: 'telemetry' });
108
- }
85
+ startPendingIdle(resolvedId, serviceName);
109
86
  }
110
87
 
111
88
  const agentSessionId = attrs['session.id'] || attrs['conversation.id'];
@@ -115,7 +92,6 @@ function handleLogs(req, res) {
115
92
  if (!sess.sessionToken || dominated) {
116
93
  sess.sessionToken = agentSessionId;
117
94
  console.log(`Telemetry: captured session ID ${agentSessionId} for ${agent} (${resolvedId.slice(0, 8)})`);
118
- captured = true;
119
95
  }
120
96
  }
121
97
  }
@@ -147,16 +123,44 @@ function cancelPendingSetup(sessionId) {
147
123
  }
148
124
  }
149
125
 
150
- // Pending idle: api_request starts a check loop. Confirm idle after 1s of output silence. Expire after 6s.
151
- function startPendingIdle(id) {
152
- cancelPendingIdle(id);
153
- const started = Date.now();
126
+ // PTY activity monitor: 2s silent idle, 2s active or user_prompt working.
127
+ // Agent working indicators in PTY output.
128
+ const CLAUDE_WORKING_RE = /[✳✽✢✻·]|Working…|thinking/;
129
+ const CODEX_WORKING_RE = /Working|•/;
130
+ const GEMINI_WORKING_RE = /[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/;
131
+
132
+ function startPendingIdle(id, agent) {
133
+ if (pendingIdle.has(id)) return; // already monitoring
134
+ const isClaude = agent === 'claude-code';
135
+ const isCodex = agent === 'codex_cli_rs';
136
+ const isGemini = agent === 'gemini-cli';
137
+ let isIdle = false;
138
+ let activeStart = 0;
154
139
  const check = setInterval(() => {
155
- const elapsed = Date.now() - started;
156
- if (elapsed > 6000) { cancelPendingIdle(id); return; }
157
- if (Date.now() - Math.max(started, ioActivity.lastOutputAt(id)) >= 1000) {
158
- cancelPendingIdle(id);
140
+ const lastOut = ioActivity.lastOutputAt(id);
141
+ const lastIn = ioActivity.lastInputAt(id);
142
+ // Ignore echo: if last output is within 100ms of last input, treat as silent
143
+ const agentOut = (lastIn && lastOut - lastIn >= 0 && lastOut - lastIn < 100) ? 0 : lastOut;
144
+ let silent = (Date.now() - agentOut) >= 2000;
145
+ // Agent override: if recent output has spinner/working chars, not silent
146
+ if (silent && (Date.now() - lastOut) < 2000) {
147
+ const chunk = ioActivity.lastChunk(id);
148
+ if (isClaude && CLAUDE_WORKING_RE.test(chunk)) silent = false;
149
+ if (isCodex && CODEX_WORKING_RE.test(chunk)) silent = false;
150
+ if (isGemini && GEMINI_WORKING_RE.test(chunk)) silent = false;
151
+ }
152
+ if (silent && !isIdle) {
153
+ isIdle = true;
154
+ activeStart = 0;
159
155
  broadcastFn?.({ type: 'session.status', id, working: false, source: 'telemetry' });
156
+ } else if (!silent && isIdle) {
157
+ if (!activeStart) activeStart = Date.now();
158
+ if (Date.now() - activeStart >= 2000) {
159
+ isIdle = false;
160
+ broadcastFn?.({ type: 'session.status', id, working: true, source: 'telemetry' });
161
+ }
162
+ } else if (!silent) {
163
+ activeStart = 0;
160
164
  }
161
165
  }, 250);
162
166
  pendingIdle.set(id, check);
@@ -167,9 +171,40 @@ function cancelPendingIdle(id) {
167
171
  if (timer) { clearInterval(timer); pendingIdle.delete(id); }
168
172
  }
169
173
 
174
+ function startEscIdle(id) {
175
+ cancelEscIdle(id);
176
+ const started = Date.now();
177
+ const ignoreUntil = started + 500;
178
+ console.log(`[escIdle] start session=${id.slice(0,8)}`);
179
+ const check = setInterval(() => {
180
+ const lastOut = ioActivity.lastOutputAt(id);
181
+ const silence = Date.now() - Math.max(ignoreUntil, lastOut);
182
+ const elapsed = Date.now() - started;
183
+ if (elapsed > 10000) {
184
+ console.log(`[escIdle] timeout session=${id.slice(0,8)} silence=${silence}ms`);
185
+ cancelEscIdle(id);
186
+ return;
187
+ }
188
+ if (silence >= 2000) {
189
+ console.log(`[escIdle] idle session=${id.slice(0,8)} silence=${silence}ms`);
190
+ escSuppressUntil.set(id, Date.now() + 2000);
191
+ cancelEscIdle(id);
192
+ broadcastFn?.({ type: 'session.status', id, working: false, source: 'esc' });
193
+ }
194
+ }, 250);
195
+ escPendingIdle.set(id, check);
196
+ }
197
+
198
+ function cancelEscIdle(id) {
199
+ const timer = escPendingIdle.get(id);
200
+ if (timer) { clearInterval(timer); escPendingIdle.delete(id); }
201
+ }
202
+
170
203
  function clear(id) {
171
204
  activity.delete(id);
172
205
  cancelPendingIdle(id);
206
+ cancelEscIdle(id);
207
+ escSuppressUntil.delete(id);
173
208
  const pending = pendingSetup.get(id);
174
209
  if (pending) { clearTimeout(pending.timer); pendingSetup.delete(id); }
175
210
  }
@@ -179,4 +214,4 @@ function hasEvents(id) {
179
214
  return activity.has(id);
180
215
  }
181
216
 
182
- module.exports = { init, handleLogs, clear, hasEvents, watchSession };
217
+ module.exports = { init, handleLogs, clear, hasEvents, watchSession, startPendingIdle, startEscIdle };
package/transcript.js CHANGED
@@ -254,15 +254,27 @@ function anchorParse(id, lines) {
254
254
  return turns;
255
255
  }
256
256
 
257
+ // For consecutive agent turns, keep only the last one (strips tool output, keeps conversational response).
258
+ function collapseAgentTurns(turns) {
259
+ if (!turns?.length) return turns;
260
+ const result = [];
261
+ for (let i = 0; i < turns.length; i++) {
262
+ if (turns[i].role === 'agent' && i + 1 < turns.length && turns[i + 1].role === 'agent') continue;
263
+ result.push(turns[i]);
264
+ }
265
+ return result;
266
+ }
267
+
257
268
  // Parse .screen into structured turns — use agent-specific parser if available, else anchor fallback.
258
- function getScreenTurns(id, agent) {
269
+ // opts.raw: if true, preserve trailing user turn (needed by autopilot for freshness checks).
270
+ function getScreenTurns(id, agent, opts) {
259
271
  const screen = getScreen(id);
260
272
  if (!screen) return null;
261
273
  const lines = screen.split('\n');
262
274
  const parser = agentParsers[agent];
263
- const turns = parser ? parser(lines, id) : anchorParse(id, lines);
275
+ const turns = collapseAgentTurns(parser ? parser(lines, id) : anchorParse(id, lines));
264
276
  // Drop trailing user turn — it's the empty prompt or unanswered input
265
- if (turns?.length && turns[turns.length - 1].role === 'user') turns.pop();
277
+ if (!opts?.raw && turns?.length && turns[turns.length - 1].role === 'user') turns.pop();
266
278
  return turns?.length >= 2 ? turns : null;
267
279
  }
268
280