clideck 1.26.0 → 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) {
@@ -111,10 +127,11 @@ function spawnSession(id, cmd, parts, cwd, name, themeId, commandId, savedToken,
111
127
  opencodeBridge.clear(id);
112
128
  plugins.clearStatus(id);
113
129
  // If resumable and token captured, move to resumable list (keep transcript for search)
114
- if (cmd.canResume && cmd.resumeCommand && s.sessionToken) {
130
+ if (!s.ephemeral && cmd.canResume && cmd.resumeCommand && s.sessionToken) {
115
131
  resumable.push({
116
132
  id, name: s.name, commandId: s.commandId, presetId: s.presetId || 'shell', cwd: s.cwd,
117
133
  themeId: s.themeId, sessionToken: s.sessionToken, projectId: s.projectId, muted: !!s.muted,
134
+ roleName: s.roleName || null,
118
135
  lastPreview: s.lastPreview || '', lastActivityAt: s.lastActivityAt || null,
119
136
  savedAt: new Date().toISOString(),
120
137
  });
@@ -124,7 +141,7 @@ function spawnSession(id, cmd, parts, cwd, name, themeId, commandId, savedToken,
124
141
  }
125
142
  sessions.delete(id);
126
143
  broadcast({ type: 'closed', id });
127
- if (cmd.canResume && s.sessionToken) {
144
+ if (!s.ephemeral && cmd.canResume && s.sessionToken) {
128
145
  broadcast({ type: 'sessions.resumable', list: getResumable() });
129
146
  }
130
147
  });
@@ -152,6 +169,18 @@ function create(msg, ws, cfg) {
152
169
  return;
153
170
  }
154
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
+
155
184
  const createdPresetId = PRESETS.find(p => binName(p.command) === binName(cmd.command))?.presetId || 'shell';
156
185
  const installId = msg.installId || undefined;
157
186
  broadcast({ type: 'created', id, name, themeId, commandId: cmd.id, presetId: createdPresetId, projectId, installId });
@@ -164,6 +193,33 @@ function create(msg, ws, cfg) {
164
193
  }
165
194
  }
166
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
+
167
223
  // --- Resume a persisted session ---
168
224
 
169
225
  function resume(msg, ws, cfg) {
@@ -200,7 +256,11 @@ function resume(msg, ws, cfg) {
200
256
  return;
201
257
  }
202
258
 
203
- 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
+ }
204
264
 
205
265
  // Remove from resumable list and notify all clients
206
266
  resumable = resumable.filter(s => s.id !== id);
@@ -217,6 +277,11 @@ function input(msg) {
217
277
  activity.trackIn(msg.id, data.length);
218
278
  transcript.trackInput(msg.id, data);
219
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
+ }
220
285
  }
221
286
  function resize(msg) { sessions.get(msg.id)?.pty.resize(msg.cols, msg.rows); }
222
287
 
@@ -295,6 +360,7 @@ function restart(msg, ws, cfg) {
295
360
  function list() {
296
361
  return [...sessions].map(([id, s]) => ({
297
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,
298
364
  // Last preview text for sidebar display on reconnect
299
365
  lastPreview: s.lastPreview || '', lastActivityAt: s.lastActivityAt || null,
300
366
  menu: s._menuKey ? JSON.parse(s._menuKey) : undefined,
@@ -340,6 +406,7 @@ function saveSessions(cfg) {
340
406
  let skippedNoToken = 0;
341
407
  const live = [...sessions]
342
408
  .filter(([, s]) => {
409
+ if (s.ephemeral) return false;
343
410
  const cmd = cfg.commands.find(c => c.id === s.commandId);
344
411
  if (!cmd?.canResume || !cmd.resumeCommand) return false;
345
412
  // If resume needs a session ID, we must have captured one
@@ -352,6 +419,7 @@ function saveSessions(cfg) {
352
419
  .map(([id, s]) => ({
353
420
  id, name: s.name, commandId: s.commandId, presetId: s.presetId || 'shell', cwd: s.cwd,
354
421
  themeId: s.themeId, sessionToken: s.sessionToken, projectId: s.projectId, muted: !!s.muted,
422
+ roleName: s.roleName || null,
355
423
  lastPreview: s.lastPreview || '', lastActivityAt: s.lastActivityAt || null,
356
424
  savedAt: new Date().toISOString(),
357
425
  }));
@@ -402,7 +470,7 @@ function shutdown(cfg) {
402
470
 
403
471
  module.exports = {
404
472
  clients, broadcast, addBroadcastListener, getSessions: () => sessions,
405
- create, resume, restart, input, resize, rename, setTheme, setMute, setProject, setPreview, close,
473
+ create, createProgrammatic, resume, restart, input, resize, rename, setTheme, setMute, setProject, setPreview, close,
406
474
  list, getResumable, sendBuffers,
407
475
  loadSessions, startAutoSave, shutdown,
408
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,50 +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)) {
82
83
  cancelPendingIdle(resolvedId);
83
84
  broadcastFn?.({ type: 'session.status', id: resolvedId, working: true, source: 'telemetry' });
84
- }
85
- // Claude: telemetry-only status. api_request → pending idle (confirm after 1s output silence, expire after 6s).
86
- if (serviceName === 'claude-code' && eventName) {
87
- if (eventName === 'api_request') {
88
- startPendingIdle(resolvedId);
89
- } else if (eventName !== 'user_prompt') {
90
- cancelPendingIdle(resolvedId);
91
- broadcastFn?.({ type: 'session.status', id: resolvedId, working: true, source: 'telemetry' });
92
- }
93
- }
94
- // Codex: codex.sse_event → pending idle (2s PTY silence), other events → working.
95
- if (serviceName === 'codex_cli_rs' && eventName) {
96
- if (eventName === 'codex.sse_event') {
97
- startPendingIdle(resolvedId);
98
- } else if (eventName !== 'codex.user_prompt') {
99
- cancelPendingIdle(resolvedId);
100
- broadcastFn?.({ type: 'session.status', id: resolvedId, working: true, source: 'telemetry' });
101
- }
102
- }
103
- // Gemini: api_response (role=main) → pending idle (2s PTY silence), whitelisted events → working.
104
- if (serviceName === 'gemini-cli' && eventName) {
105
- if (eventName === 'gemini_cli.api_response' && attrs['role'] === 'main') {
106
- startPendingIdle(resolvedId);
107
- } else if (eventName === 'gemini_cli.api_request' || eventName === 'gemini_cli.model_routing'
108
- || (eventName === 'gemini_cli.api_response' && attrs['role'] !== 'main')) {
109
- cancelPendingIdle(resolvedId);
110
- broadcastFn?.({ type: 'session.status', id: resolvedId, working: true, source: 'telemetry' });
111
- }
85
+ startPendingIdle(resolvedId, serviceName);
112
86
  }
113
87
 
114
88
  const agentSessionId = attrs['session.id'] || attrs['conversation.id'];
@@ -118,7 +92,6 @@ function handleLogs(req, res) {
118
92
  if (!sess.sessionToken || dominated) {
119
93
  sess.sessionToken = agentSessionId;
120
94
  console.log(`Telemetry: captured session ID ${agentSessionId} for ${agent} (${resolvedId.slice(0, 8)})`);
121
- captured = true;
122
95
  }
123
96
  }
124
97
  }
@@ -150,14 +123,44 @@ function cancelPendingSetup(sessionId) {
150
123
  }
151
124
  }
152
125
 
153
- // Pending idle: starts a check loop. Confirm idle after 2s of PTY output silence.
154
- function startPendingIdle(id) {
155
- cancelPendingIdle(id);
156
- 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;
157
139
  const check = setInterval(() => {
158
- if (Date.now() - Math.max(started, ioActivity.lastOutputAt(id)) >= 2000) {
159
- 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;
160
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;
161
164
  }
162
165
  }, 250);
163
166
  pendingIdle.set(id, check);
@@ -168,9 +171,40 @@ function cancelPendingIdle(id) {
168
171
  if (timer) { clearInterval(timer); pendingIdle.delete(id); }
169
172
  }
170
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
+
171
203
  function clear(id) {
172
204
  activity.delete(id);
173
205
  cancelPendingIdle(id);
206
+ cancelEscIdle(id);
207
+ escSuppressUntil.delete(id);
174
208
  const pending = pendingSetup.get(id);
175
209
  if (pending) { clearTimeout(pending.timer); pendingSetup.delete(id); }
176
210
  }
@@ -180,4 +214,4 @@ function hasEvents(id) {
180
214
  return activity.has(id);
181
215
  }
182
216
 
183
- 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