@archznn/crewloop-skills 0.4.3 → 0.6.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.
Files changed (77) hide show
  1. package/package.json +3 -2
  2. package/packages/cli/dist/agents.d.ts +9 -0
  3. package/packages/cli/dist/agents.d.ts.map +1 -1
  4. package/packages/cli/dist/agents.js +62 -5
  5. package/packages/cli/dist/agents.js.map +1 -1
  6. package/packages/cli/dist/cli.d.ts +1 -0
  7. package/packages/cli/dist/cli.d.ts.map +1 -1
  8. package/packages/cli/dist/cli.js +57 -5
  9. package/packages/cli/dist/cli.js.map +1 -1
  10. package/packages/cli/dist/hooks.d.ts +37 -0
  11. package/packages/cli/dist/hooks.d.ts.map +1 -0
  12. package/packages/cli/dist/hooks.js +282 -0
  13. package/packages/cli/dist/hooks.js.map +1 -0
  14. package/packages/cli/dist/mcp.d.ts +8 -0
  15. package/packages/cli/dist/mcp.d.ts.map +1 -1
  16. package/packages/cli/dist/mcp.js +19 -1
  17. package/packages/cli/dist/mcp.js.map +1 -1
  18. package/packages/cli/dist/tests/cli.test.js +21 -0
  19. package/packages/cli/dist/tests/cli.test.js.map +1 -1
  20. package/packages/cli/dist/tests/hooks.test.d.ts +2 -0
  21. package/packages/cli/dist/tests/hooks.test.d.ts.map +1 -0
  22. package/packages/cli/dist/tests/hooks.test.js +179 -0
  23. package/packages/cli/dist/tests/hooks.test.js.map +1 -0
  24. package/packages/cli/dist/tests/mcp.test.js +79 -0
  25. package/packages/cli/dist/tests/mcp.test.js.map +1 -1
  26. package/servers/dashboard/bin/crewloop-shim.js +4 -0
  27. package/servers/dashboard/dist/adapters/codex.d.ts +1 -0
  28. package/servers/dashboard/dist/adapters/codex.d.ts.map +1 -1
  29. package/servers/dashboard/dist/adapters/codex.js +1 -0
  30. package/servers/dashboard/dist/adapters/codex.js.map +1 -1
  31. package/servers/dashboard/dist/adapters/kimi.d.ts +1 -0
  32. package/servers/dashboard/dist/adapters/kimi.d.ts.map +1 -1
  33. package/servers/dashboard/dist/adapters/kimi.js +1 -0
  34. package/servers/dashboard/dist/adapters/kimi.js.map +1 -1
  35. package/servers/dashboard/dist/adapters/shim.d.ts +2 -1
  36. package/servers/dashboard/dist/adapters/shim.d.ts.map +1 -1
  37. package/servers/dashboard/dist/adapters/shim.js +15 -2
  38. package/servers/dashboard/dist/adapters/shim.js.map +1 -1
  39. package/servers/dashboard/dist/adapters/shim.test.js +43 -0
  40. package/servers/dashboard/dist/adapters/shim.test.js.map +1 -1
  41. package/servers/dashboard/dist/presenter.d.ts.map +1 -1
  42. package/servers/dashboard/dist/presenter.js +2 -0
  43. package/servers/dashboard/dist/presenter.js.map +1 -1
  44. package/servers/dashboard/dist/presenter.test.js +7 -0
  45. package/servers/dashboard/dist/presenter.test.js.map +1 -1
  46. package/servers/dashboard/dist/skills/infer.d.ts.map +1 -1
  47. package/servers/dashboard/dist/skills/infer.js +5 -0
  48. package/servers/dashboard/dist/skills/infer.js.map +1 -1
  49. package/servers/dashboard/dist/skills/infer.test.js +9 -0
  50. package/servers/dashboard/dist/skills/infer.test.js.map +1 -1
  51. package/servers/dashboard/dist/state.d.ts.map +1 -1
  52. package/servers/dashboard/dist/state.js +19 -1
  53. package/servers/dashboard/dist/state.js.map +1 -1
  54. package/servers/dashboard/dist/state.test.js +37 -0
  55. package/servers/dashboard/dist/state.test.js.map +1 -1
  56. package/servers/dashboard/dist/tests/shim.test.d.ts +2 -0
  57. package/servers/dashboard/dist/tests/shim.test.d.ts.map +1 -0
  58. package/servers/dashboard/dist/tests/shim.test.js +47 -0
  59. package/servers/dashboard/dist/tests/shim.test.js.map +1 -0
  60. package/servers/dashboard/dist/types.d.ts +4 -0
  61. package/servers/dashboard/dist/types.d.ts.map +1 -1
  62. package/servers/dashboard/package.json +3 -2
  63. package/servers/dashboard/public/app.js +81 -12
  64. package/servers/dashboard/public/styles.css +174 -19
  65. package/servers/dashboard/src/adapters/codex.ts +2 -0
  66. package/servers/dashboard/src/adapters/kimi.ts +2 -0
  67. package/servers/dashboard/src/adapters/shim.test.ts +64 -1
  68. package/servers/dashboard/src/adapters/shim.ts +18 -2
  69. package/servers/dashboard/src/presenter.test.ts +8 -0
  70. package/servers/dashboard/src/presenter.ts +2 -0
  71. package/servers/dashboard/src/skills/infer.test.ts +10 -0
  72. package/servers/dashboard/src/skills/infer.ts +8 -0
  73. package/servers/dashboard/src/state.test.ts +43 -0
  74. package/servers/dashboard/src/state.ts +20 -1
  75. package/servers/dashboard/src/tests/shim.test.ts +45 -0
  76. package/servers/dashboard/src/types.ts +4 -0
  77. package/skills/orchestrator/SKILL.md +6 -0
@@ -219,27 +219,89 @@
219
219
  renderActivityGraph(session);
220
220
  }
221
221
 
222
+ let lifecycleBadgeEl = null;
223
+ let emptyStateEl = null;
224
+
225
+ function getActiveSkillContent() {
226
+ return activeSkillName.parentElement;
227
+ }
228
+
229
+ function setChildrenVisibility(visible) {
230
+ const content = getActiveSkillContent();
231
+ if (!content) return;
232
+ Array.from(content.children).forEach((child) => {
233
+ if (child === emptyStateEl) return;
234
+ child.style.display = visible ? '' : 'none';
235
+ });
236
+ }
237
+
238
+ function renderEmptyState() {
239
+ if (!emptyStateEl) {
240
+ emptyStateEl = document.createElement('div');
241
+ emptyStateEl.className = 'empty-state';
242
+ emptyStateEl.innerHTML = `
243
+ <div class="empty-state-icon"><i class="ph ph-monitor-play"></i></div>
244
+ <h2 class="empty-state-title">NO ACTIVE SESSION</h2>
245
+ <p class="empty-state-body">Start an agent session to see it here.</p>
246
+ `;
247
+ getActiveSkillContent()?.appendChild(emptyStateEl);
248
+ }
249
+ emptyStateEl.style.display = 'flex';
250
+ setChildrenVisibility(false);
251
+ }
252
+
253
+ function hideEmptyState() {
254
+ if (emptyStateEl) {
255
+ emptyStateEl.style.display = 'none';
256
+ }
257
+ setChildrenVisibility(true);
258
+ }
259
+
260
+ function renderLifecycleBadge(session) {
261
+ if (!lifecycleBadgeEl) {
262
+ lifecycleBadgeEl = document.createElement('span');
263
+ lifecycleBadgeEl.className = 'lifecycle-badge';
264
+ statusBadge.parentNode?.insertBefore(lifecycleBadgeEl, statusBadge.nextSibling);
265
+ }
266
+ const lifecycle = session.lifecycle || 'starting';
267
+ lifecycleBadgeEl.className = 'lifecycle-badge ' + lifecycle;
268
+ lifecycleBadgeEl.innerHTML = `<span class="lifecycle-dot"></span>${lifecycle.toUpperCase()}`;
269
+ lifecycleBadgeEl.style.display = 'inline-flex';
270
+ }
271
+
272
+ function hideLifecycleBadge() {
273
+ if (lifecycleBadgeEl) {
274
+ lifecycleBadgeEl.style.display = 'none';
275
+ }
276
+ }
277
+
222
278
  function renderActiveSkill(session) {
223
279
  if (!session) {
224
- activeSkillName.textContent = 'IDLE';
225
- activeSkillIcon.className = 'ph ph-circle';
280
+ renderEmptyState();
226
281
  activeStrip.className = 'panel-accent-strip';
227
- statusDot.className = 'status-dot';
228
- statusText.textContent = 'IDLE';
229
- confidenceBadge.textContent = 'unknown';
230
- activeSkillSource.innerHTML = '<i class="ph ph-monitor"></i><span>waiting for events</span>';
282
+ hideLifecycleBadge();
231
283
  return;
232
284
  }
233
285
 
286
+ hideEmptyState();
287
+
234
288
  const skill = session.activeSkill || { name: 'UNKNOWN', confidence: 'low' };
235
289
  const iconClass = skillIcon(skill.name);
236
290
  activeSkillName.textContent = skill.name.toUpperCase();
237
291
  activeSkillIcon.className = 'ph ' + iconClass;
238
292
 
239
- const running = session.status === 'running';
240
- activeStrip.className = 'panel-accent-strip' + (running ? ' running' : '');
241
- statusDot.className = 'status-dot ' + (running ? 'running' : session.status || '');
242
- statusText.textContent = running ? 'RUNNING' : (session.status ? session.status.toUpperCase() : 'IDLE');
293
+ const lifecycle = session.lifecycle || 'starting';
294
+ activeStrip.className = 'panel-accent-strip ' + lifecycle;
295
+
296
+ if (lifecycle === 'ended') {
297
+ statusDot.className = 'status-dot ' + (session.status || '');
298
+ statusText.textContent = session.status ? session.status.toUpperCase() : 'ENDED';
299
+ } else {
300
+ statusDot.className = 'status-dot ' + lifecycle;
301
+ statusText.textContent = lifecycle.toUpperCase();
302
+ }
303
+
304
+ renderLifecycleBadge(session);
243
305
  confidenceBadge.textContent = skill.confidence || 'low';
244
306
 
245
307
  activeSkillSource.innerHTML = `<i class="ph ph-${sourceIcon(session.source)}"></i><span>${session.source || 'unknown'}</span>`;
@@ -265,7 +327,8 @@
265
327
  const events = session.events || [];
266
328
  const toolEvents = events.filter((e) => e.event_type === 'tool_start' || e.event_type === 'tool_end');
267
329
  toolCount.textContent = String(Math.ceil(toolEvents.length / 2));
268
- const dur = session.lastActivity && session.startTime ? session.lastActivity - session.startTime : 0;
330
+ const endTime = session.endedAt || session.lastActivity;
331
+ const dur = endTime && session.startTime ? endTime - session.startTime : 0;
269
332
  durationEl.textContent = formatDuration(dur);
270
333
  updateEventRate();
271
334
  }
@@ -383,8 +446,14 @@
383
446
  li.className = 'session-item' + (s.id === state.selectedSessionId ? ' active' : '');
384
447
  li.setAttribute('role', 'option');
385
448
  li.setAttribute('aria-selected', String(s.id === state.selectedSessionId));
449
+ const duration = s.endedAt
450
+ ? `ended after ${formatDuration(s.endedAt - s.startTime)}`
451
+ : formatDuration(Date.now() - s.startTime);
386
452
  li.innerHTML = `
387
- <span class="session-item-id">${truncate(s.id, 16)}</span>
453
+ <div class="session-item-main">
454
+ <span class="session-item-id">${truncate(s.id, 16)}</span>
455
+ <span class="session-item-meta">${formatTime(s.startTime)} · ${duration}</span>
456
+ </div>
388
457
  <span class="session-item-source">${s.source || 'unknown'}</span>
389
458
  `;
390
459
  li.addEventListener('click', () => {
@@ -16,6 +16,14 @@
16
16
  --warning: #facc15;
17
17
  --running: #38bdf8;
18
18
 
19
+ /* lifecycle tokens */
20
+ --lifecycle-starting: var(--warning);
21
+ --lifecycle-running: var(--running);
22
+ --lifecycle-ended: var(--text-muted);
23
+ --lifecycle-starting-bg: rgba(250, 204, 21, 0.12);
24
+ --lifecycle-running-bg: rgba(56, 189, 248, 0.12);
25
+ --lifecycle-ended-bg: rgba(148, 163, 184, 0.10);
26
+
19
27
  --font-display: 'Bebas Neue', 'Oswald', sans-serif;
20
28
  --font-body: 'DM Sans', system-ui, sans-serif;
21
29
  --font-mono: 'JetBrains Mono', ui-monospace, monospace;
@@ -237,6 +245,121 @@ html, body {
237
245
  text-transform: uppercase;
238
246
  }
239
247
 
248
+ .session-item-main {
249
+ display: flex;
250
+ flex-direction: column;
251
+ gap: 2px;
252
+ min-width: 0;
253
+ }
254
+
255
+ .session-item-meta {
256
+ font-size: 0.6875rem;
257
+ color: var(--text-muted);
258
+ }
259
+
260
+ /* Lifecycle badge */
261
+ .lifecycle-badge {
262
+ display: inline-flex;
263
+ align-items: center;
264
+ gap: 6px;
265
+ padding: 4px 10px;
266
+ border-radius: 999px;
267
+ font-size: 0.75rem;
268
+ font-weight: 600;
269
+ line-height: 1;
270
+ letter-spacing: 0.06em;
271
+ text-transform: uppercase;
272
+ border: 1px solid var(--border-default);
273
+ background: var(--bg-elevated);
274
+ color: var(--text-secondary);
275
+ animation: lifecycle-enter 0.2s ease-out;
276
+ }
277
+
278
+ .lifecycle-badge.starting {
279
+ color: var(--lifecycle-starting);
280
+ background: var(--lifecycle-starting-bg);
281
+ border-color: rgba(250, 204, 21, 0.35);
282
+ }
283
+
284
+ .lifecycle-badge.running {
285
+ color: var(--lifecycle-running);
286
+ background: var(--lifecycle-running-bg);
287
+ border-color: rgba(56, 189, 248, 0.35);
288
+ }
289
+
290
+ .lifecycle-badge.ended {
291
+ color: var(--lifecycle-ended);
292
+ background: var(--lifecycle-ended-bg);
293
+ border-color: var(--border-default);
294
+ }
295
+
296
+ @keyframes lifecycle-enter {
297
+ from {
298
+ opacity: 0;
299
+ transform: translateY(-4px);
300
+ }
301
+ to {
302
+ opacity: 1;
303
+ transform: translateY(0);
304
+ }
305
+ }
306
+
307
+ /* Empty state */
308
+ .empty-state {
309
+ display: flex;
310
+ flex-direction: column;
311
+ align-items: center;
312
+ justify-content: center;
313
+ gap: 12px;
314
+ padding: 48px 24px;
315
+ text-align: center;
316
+ color: var(--text-secondary);
317
+ animation: empty-state-enter 0.25s ease-out;
318
+ }
319
+
320
+ .empty-state-icon {
321
+ width: 64px;
322
+ height: 64px;
323
+ border-radius: 16px;
324
+ background: var(--bg-elevated);
325
+ border: 1px solid var(--border-default);
326
+ display: flex;
327
+ align-items: center;
328
+ justify-content: center;
329
+ color: var(--text-muted);
330
+ }
331
+
332
+ .empty-state-icon i {
333
+ font-size: 2rem;
334
+ }
335
+
336
+ .empty-state-title {
337
+ font-family: var(--font-display);
338
+ font-size: 2rem;
339
+ font-weight: 400;
340
+ line-height: 1.1;
341
+ letter-spacing: 0.04em;
342
+ color: var(--text-primary);
343
+ margin: 0;
344
+ }
345
+
346
+ .empty-state-body {
347
+ font-size: 0.875rem;
348
+ color: var(--text-secondary);
349
+ margin: 0;
350
+ }
351
+
352
+ @keyframes empty-state-enter {
353
+ from {
354
+ opacity: 0;
355
+ transform: translateY(8px);
356
+ }
357
+ to {
358
+ opacity: 1;
359
+ transform: translateY(0);
360
+ }
361
+ }
362
+
240
363
  /* Main layout */
241
364
  .main {
242
365
  display: flex;
@@ -294,12 +417,24 @@ html, body {
294
417
  right: 0;
295
418
  height: 4px;
296
419
  background: var(--text-muted);
297
- transition: background 0.2s ease;
420
+ transition: background 0.2s ease, box-shadow 0.2s ease;
421
+ }
422
+
423
+ .panel-accent-strip.starting {
424
+ background: var(--lifecycle-starting);
425
+ box-shadow: 0 0 12px var(--lifecycle-starting);
426
+ animation: lifecycle-glow 2s ease-in-out infinite;
298
427
  }
299
428
 
300
429
  .panel-accent-strip.running {
301
- background: var(--accent);
302
- box-shadow: 0 0 16px var(--accent-dim);
430
+ background: var(--lifecycle-running);
431
+ box-shadow: 0 0 12px var(--lifecycle-running);
432
+ animation: lifecycle-glow 2s ease-in-out infinite;
433
+ }
434
+
435
+ .panel-accent-strip.ended {
436
+ background: var(--border-strong);
437
+ box-shadow: none;
303
438
  }
304
439
 
305
440
  .active-skill-content {
@@ -363,8 +498,16 @@ html, body {
363
498
  background: var(--text-muted);
364
499
  }
365
500
 
501
+ .status-dot.starting {
502
+ background: var(--lifecycle-starting);
503
+ }
504
+
366
505
  .status-dot.running {
367
- background: var(--running);
506
+ background: var(--lifecycle-running);
507
+ }
508
+
509
+ .status-dot.ended {
510
+ background: var(--lifecycle-ended);
368
511
  }
369
512
 
370
513
  .status-dot.error {
@@ -561,31 +704,43 @@ html, body {
561
704
  }
562
705
  }
563
706
 
564
- @keyframes pulse {
565
- 0%, 100% {
566
- opacity: 1;
567
- transform: scale(1);
568
- }
569
- 50% {
570
- opacity: 0.4;
571
- transform: scale(1.2);
572
- }
707
+ .status-dot.running,
708
+ .status-dot.starting {
709
+ animation: lifecycle-pulse 1.5s ease-in-out infinite;
573
710
  }
574
711
 
575
- .status-dot.running {
576
- animation: pulse 1.2s ease-in-out infinite;
712
+ .lifecycle-dot {
713
+ width: 6px;
714
+ height: 6px;
715
+ border-radius: 50%;
716
+ background: currentColor;
577
717
  }
578
718
 
719
+ .lifecycle-badge.starting .lifecycle-dot,
720
+ .lifecycle-badge.running .lifecycle-dot {
721
+ animation: lifecycle-pulse 1.5s ease-in-out infinite;
722
+ }
723
+
724
+ .panel-accent-strip.starting,
579
725
  .panel-accent-strip.running {
580
- animation: glow 2s ease-in-out infinite;
726
+ animation: lifecycle-glow 2s ease-in-out infinite;
727
+ }
728
+
729
+ @keyframes lifecycle-pulse {
730
+ 0%, 100% {
731
+ opacity: 1;
732
+ }
733
+ 50% {
734
+ opacity: 0.5;
735
+ }
581
736
  }
582
737
 
583
- @keyframes glow {
738
+ @keyframes lifecycle-glow {
584
739
  0%, 100% {
585
- box-shadow: 0 0 0 transparent;
740
+ box-shadow: 0 0 8px currentColor;
586
741
  }
587
742
  50% {
588
- box-shadow: 0 0 16px var(--accent-dim);
743
+ box-shadow: 0 0 16px currentColor;
589
744
  }
590
745
  }
591
746
 
@@ -19,6 +19,7 @@ export interface CodexHookPayload {
19
19
  executed?: boolean;
20
20
  success?: boolean;
21
21
  durationMs?: number;
22
+ skill?: string;
22
23
  }
23
24
 
24
25
  const EVENT_MAP: Record<string, EventType> = {
@@ -42,6 +43,7 @@ export function normalizeCodex(payload: CodexHookPayload): DashboardEvent | unde
42
43
  session_id: payload.sessionId || payload.session_id || 'unknown',
43
44
  event_type,
44
45
  tool: payload.toolName,
46
+ skill: payload.skill,
45
47
  };
46
48
  }
47
49
 
@@ -9,6 +9,7 @@ export interface KimiHookPayload {
9
9
  tool_response?: Record<string, unknown>;
10
10
  stop_reason?: string;
11
11
  usage?: unknown;
12
+ skill?: string;
12
13
  }
13
14
 
14
15
  const EVENT_MAP: Record<string, EventType> = {
@@ -32,6 +33,7 @@ export function normalizeKimi(payload: KimiHookPayload): DashboardEvent | undefi
32
33
  session_id: payload.session_id || 'unknown',
33
34
  event_type,
34
35
  tool: payload.tool_name,
36
+ skill: payload.skill,
35
37
  };
36
38
  }
37
39
 
@@ -1,6 +1,6 @@
1
1
  import { describe, it } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { detectSource, buildEvent } from './shim';
3
+ import { detectSource, buildEvent, getDefaultSkill } from './shim';
4
4
  import type { AgentSource, DashboardEvent } from '../types';
5
5
 
6
6
  describe('detectSource', () => {
@@ -20,6 +20,22 @@ describe('detectSource', () => {
20
20
  });
21
21
  });
22
22
 
23
+ describe('getDefaultSkill', () => {
24
+ it('reads default skill from argv', () => {
25
+ assert.equal(getDefaultSkill(['node', 'shim', 'kimi', '--default-skill', 'orchestrator']), 'orchestrator');
26
+ });
27
+
28
+ it('falls back to env var', () => {
29
+ process.env.CREWLOOP_DEFAULT_SKILL = 'architect';
30
+ assert.equal(getDefaultSkill(['node', 'shim', 'kimi']), 'architect');
31
+ delete process.env.CREWLOOP_DEFAULT_SKILL;
32
+ });
33
+
34
+ it('returns undefined when not configured', () => {
35
+ assert.equal(getDefaultSkill(['node', 'shim', 'kimi']), undefined);
36
+ });
37
+ });
38
+
23
39
  describe('buildEvent', () => {
24
40
  it('builds Kimi PreToolUse event', () => {
25
41
  const event = buildEvent('kimi' as AgentSource, {
@@ -71,4 +87,51 @@ describe('buildEvent', () => {
71
87
  });
72
88
  assert.equal(event, undefined);
73
89
  });
90
+
91
+ it('attaches default skill to session_start events', () => {
92
+ const event = buildEvent(
93
+ 'kimi' as AgentSource,
94
+ { hook_event_name: 'SessionStart', session_id: 'sess-1', cwd: '/project' },
95
+ 'orchestrator'
96
+ );
97
+ assert.equal(event?.event_type, 'session_start');
98
+ assert.equal(event?.skill, 'orchestrator');
99
+ });
100
+
101
+ it('does not attach default skill to tool events', () => {
102
+ const startEvent = buildEvent(
103
+ 'kimi' as AgentSource,
104
+ { hook_event_name: 'PreToolUse', session_id: 'sess-1', cwd: '/project', tool_name: 'Read' },
105
+ 'orchestrator'
106
+ );
107
+ assert.equal(startEvent?.event_type, 'tool_start');
108
+ assert.equal(startEvent?.skill, undefined);
109
+
110
+ const endEvent = buildEvent(
111
+ 'kimi' as AgentSource,
112
+ { hook_event_name: 'PostToolUse', session_id: 'sess-1', cwd: '/project', tool_name: 'Read' },
113
+ 'orchestrator'
114
+ );
115
+ assert.equal(endEvent?.event_type, 'tool_end');
116
+ assert.equal(endEvent?.skill, undefined);
117
+ });
118
+
119
+ it('forwards explicit payload skill for kimi', () => {
120
+ const event = buildEvent('kimi' as AgentSource, {
121
+ hook_event_name: 'SessionStart',
122
+ session_id: 'sess-1',
123
+ cwd: '/project',
124
+ skill: 'architect',
125
+ });
126
+ assert.equal(event?.skill, 'architect');
127
+ });
128
+
129
+ it('forwards explicit payload skill for codex', () => {
130
+ const event = buildEvent('codex' as AgentSource, {
131
+ sessionId: 'sess-2',
132
+ hook_event_name: 'SessionStart',
133
+ skill: 'engineer',
134
+ });
135
+ assert.equal(event?.skill, 'engineer');
136
+ });
74
137
  });
@@ -6,6 +6,15 @@ import { sanitize } from '../filters/sanitize';
6
6
 
7
7
  const DEFAULT_SERVER_URL = 'http://127.0.0.1:7890';
8
8
 
9
+ export function getDefaultSkill(argv: string[]): string | undefined {
10
+ const idx = argv.indexOf('--default-skill');
11
+ if (idx !== -1 && argv[idx + 1]) {
12
+ return argv[idx + 1];
13
+ }
14
+ const env = process.env.CREWLOOP_DEFAULT_SKILL;
15
+ return env || undefined;
16
+ }
17
+
9
18
  export function detectSource(argv: string[]): AgentSource | undefined {
10
19
  const arg = argv[2];
11
20
  if (arg === 'kimi' || arg === 'codex' || arg === 'opencode' || arg === 'log-watcher') {
@@ -42,13 +51,18 @@ export function normalizePayload(source: AgentSource, raw: unknown): DashboardEv
42
51
 
43
52
  export function buildEvent(
44
53
  source: AgentSource,
45
- raw: Record<string, unknown>
54
+ raw: Record<string, unknown>,
55
+ defaultSkill?: string
46
56
  ): DashboardEvent | undefined {
47
57
  const base = normalizePayload(source, raw);
48
58
  if (!base) {
49
59
  return undefined;
50
60
  }
51
61
 
62
+ if (base.event_type === 'session_start' && defaultSkill) {
63
+ base.skill = defaultSkill;
64
+ }
65
+
52
66
  const isPost = base.event_type === 'tool_end';
53
67
  const sanitized = sanitize(
54
68
  {
@@ -100,6 +114,8 @@ export function runShim(): void {
100
114
  process.exit(1);
101
115
  }
102
116
 
117
+ const defaultSkill = getDefaultSkill(process.argv);
118
+
103
119
  let raw = '';
104
120
  process.stdin.setEncoding('utf8');
105
121
  process.stdin.on('data', (chunk) => {
@@ -108,7 +124,7 @@ export function runShim(): void {
108
124
  process.stdin.on('end', () => {
109
125
  try {
110
126
  const payload = JSON.parse(raw);
111
- const event = buildEvent(source, payload);
127
+ const event = buildEvent(source, payload, defaultSkill);
112
128
  if (event) {
113
129
  postEvent(event);
114
130
  }
@@ -14,6 +14,7 @@ function makeSession(overrides: Partial<Session> = {}): Session {
14
14
  active_skill: 'architect',
15
15
  active_confidence: 'explicit',
16
16
  status: 'running',
17
+ lifecycle: 'running',
17
18
  ...overrides,
18
19
  };
19
20
  }
@@ -28,11 +29,18 @@ describe('presenter', () => {
28
29
  assert.equal(client.skill, 'architect');
29
30
  assert.deepEqual(client.activeSkill, { name: 'architect', confidence: 'explicit' });
30
31
  assert.equal(client.status, 'running');
32
+ assert.equal(client.lifecycle, 'running');
31
33
  assert.equal(client.startTime, 1000);
32
34
  assert.equal(client.lastActivity, 2000);
33
35
  assert.deepEqual(client.toolCounts, { Read: 2 });
34
36
  });
35
37
 
38
+ it('includes lifecycle and endedAt', () => {
39
+ const client = presentSession(makeSession({ lifecycle: 'ended', ended_at: 5000 }));
40
+ assert.equal(client.lifecycle, 'ended');
41
+ assert.equal(client.endedAt, 5000);
42
+ });
43
+
36
44
  it('omits active skill when not set', () => {
37
45
  const client = presentSession(makeSession({ active_skill: undefined }));
38
46
  assert.equal(client.activeSkill, undefined);
@@ -26,9 +26,11 @@ export function presentSession(session: Session): ClientSession {
26
26
  }
27
27
  : undefined,
28
28
  status: session.status,
29
+ lifecycle: session.lifecycle,
29
30
  events: session.events.map(presentEvent),
30
31
  startTime: session.started_at,
31
32
  lastActivity: session.last_event_at,
33
+ endedAt: session.ended_at,
32
34
  toolCounts: session.tool_counts,
33
35
  };
34
36
  }
@@ -9,6 +9,7 @@ function makeSession(overrides: Partial<Session> = {}): Session {
9
9
  source: 'kimi',
10
10
  events: [],
11
11
  tool_counts: {},
12
+ lifecycle: 'running',
12
13
  started_at: Date.now(),
13
14
  last_event_at: Date.now(),
14
15
  ...overrides,
@@ -76,6 +77,15 @@ describe('SkillInferenceEngine', () => {
76
77
  assert.equal(result.confidence, 'heuristic');
77
78
  });
78
79
 
80
+ it('preserves explicit active skill when no new explicit signal arrives', () => {
81
+ const engine = new SkillInferenceEngine(skills);
82
+ const session = makeSession({ active_skill: 'orchestrator', active_confidence: 'explicit' });
83
+ const event = makeEvent({ tool: 'Read', detail: 'README.md' });
84
+ const result = engine.infer(event, session);
85
+ assert.equal(result.skill, 'orchestrator');
86
+ assert.equal(result.confidence, 'explicit');
87
+ });
88
+
79
89
  it('returns unknown when nothing matches', () => {
80
90
  const engine = new SkillInferenceEngine(skills);
81
91
  const event = makeEvent({ tool: 'UnknownTool' });
@@ -9,6 +9,14 @@ export class SkillInferenceEngine {
9
9
  }
10
10
 
11
11
  infer(event: DashboardEvent, session: Session): SkillInferenceResult {
12
+ const explicitSignal =
13
+ (event.event_type === 'skill_change' && event.skill) ||
14
+ (event.tool === 'Skill' && event.detail && this.normalizeSkillName(event.detail));
15
+
16
+ if (session.active_confidence === 'explicit' && !explicitSignal) {
17
+ return { skill: session.active_skill, confidence: 'explicit' };
18
+ }
19
+
12
20
  if (event.event_type === 'skill_change' && event.skill) {
13
21
  return { skill: event.skill, confidence: 'explicit' };
14
22
  }
@@ -15,6 +15,7 @@ function makeEvent(overrides: Partial<DashboardEvent> = {}): DashboardEvent {
15
15
  };
16
16
  }
17
17
 
18
+
18
19
  describe('StateStore', () => {
19
20
  it('creates session on first event', () => {
20
21
  const store = new StateStore({ maxEventsPerSession: 10, sessionMaxAgeMs: 60000 });
@@ -51,6 +52,14 @@ describe('StateStore', () => {
51
52
  assert.equal(session.active_confidence, 'explicit');
52
53
  });
53
54
 
55
+ it('sets explicit active skill from session_start event', () => {
56
+ const store = new StateStore({ maxEventsPerSession: 10, sessionMaxAgeMs: 60000 });
57
+ store.applyEvent(makeEvent({ skill: 'orchestrator', event_type: 'session_start' }));
58
+ const session = store.getSession('sess-1')!;
59
+ assert.equal(session.active_skill, 'orchestrator');
60
+ assert.equal(session.active_confidence, 'explicit');
61
+ });
62
+
54
63
  it('derives running status from tool_start', () => {
55
64
  const store = new StateStore({ maxEventsPerSession: 10, sessionMaxAgeMs: 60000 });
56
65
  store.applyEvent(makeEvent({ event_type: 'tool_start' }));
@@ -85,4 +94,38 @@ describe('StateStore', () => {
85
94
  assert.equal(sessions[0].id, 'b');
86
95
  assert.equal(sessions[1].id, 'a');
87
96
  });
97
+
98
+ it('starts with lifecycle starting on session_start', () => {
99
+ const store = new StateStore({ maxEventsPerSession: 10, sessionMaxAgeMs: 60000 });
100
+ store.applyEvent(makeEvent({ event_type: 'session_start' }));
101
+ const session = store.getSession('sess-1')!;
102
+ assert.equal(session.lifecycle, 'starting');
103
+ });
104
+
105
+ it('transitions to running on first tool event', () => {
106
+ const store = new StateStore({ maxEventsPerSession: 10, sessionMaxAgeMs: 60000 });
107
+ store.applyEvent(makeEvent({ event_type: 'session_start' }));
108
+ store.applyEvent(makeEvent({ event_type: 'tool_start', tool: 'Read' }));
109
+ const session = store.getSession('sess-1')!;
110
+ assert.equal(session.lifecycle, 'running');
111
+ });
112
+
113
+ it('sets lifecycle ended and ended_at on session_end', () => {
114
+ const store = new StateStore({ maxEventsPerSession: 10, sessionMaxAgeMs: 60000 });
115
+ store.applyEvent(makeEvent({ event_type: 'session_start' }));
116
+ const endTs = Date.now() + 1000;
117
+ store.applyEvent(makeEvent({ event_type: 'session_end', timestamp: endTs }));
118
+ const session = store.getSession('sess-1')!;
119
+ assert.equal(session.lifecycle, 'ended');
120
+ assert.equal(session.ended_at, endTs);
121
+ });
122
+
123
+ it('keeps session ended after subsequent tool events', () => {
124
+ const store = new StateStore({ maxEventsPerSession: 10, sessionMaxAgeMs: 60000 });
125
+ store.applyEvent(makeEvent({ event_type: 'session_start' }));
126
+ store.applyEvent(makeEvent({ event_type: 'session_end' }));
127
+ store.applyEvent(makeEvent({ event_type: 'tool_start', tool: 'Read' }));
128
+ const session = store.getSession('sess-1')!;
129
+ assert.equal(session.lifecycle, 'ended');
130
+ });
88
131
  });