codehere 0.5.0-alpha.3 → 0.5.0-alpha.4

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/dist/index.js CHANGED
@@ -14,6 +14,7 @@ import { Command } from 'commander';
14
14
  import { config } from 'dotenv';
15
15
  import { join, dirname } from 'path';
16
16
  import { fileURLToPath } from 'url';
17
+ import { homedir } from 'os';
17
18
  import { executeSetupCommand } from './presentation/cli/commands/setup-command.js';
18
19
  import { executeInitCommand } from './presentation/cli/commands/init-command.js';
19
20
  import { executeMemoryCommand } from './presentation/cli/commands/memory-command.js';
@@ -428,6 +429,25 @@ program
428
429
  }
429
430
  };
430
431
  try {
432
+ // v0.6.0 M1+M7 — migrate sessions.jsonl → ~/.codehere/projects/<slug>/thread.jsonl
433
+ // on daemon start. Idempotent: skips projects whose thread.jsonl already exists,
434
+ // so this is safe to run on every boot. Opt-out via CODEHERE_DISABLE_THREAD_MIGRATION=1.
435
+ // Failures are non-blocking — back-compat to v0.5 sessions.jsonl is preserved.
436
+ if (process.env.CODEHERE_DISABLE_THREAD_MIGRATION !== '1') {
437
+ try {
438
+ const { migrate } = await import('./infrastructure/storage/project-thread-migration.js');
439
+ const result = migrate({
440
+ sourceJsonl: join(homedir(), '.codehere/sessions.jsonl'),
441
+ targetDir: join(homedir(), '.codehere/projects'),
442
+ });
443
+ if (result.threadsWritten > 0) {
444
+ console.log(colors.dim(` v0.6 migration: created ${result.threadsWritten} project thread(s) from ${result.sessionsProcessed} sessions (${result.durationMs}ms)`));
445
+ }
446
+ }
447
+ catch (err) {
448
+ console.warn(colors.dim(` v0.6 migration skipped: ${err instanceof Error ? err.message : String(err)}`));
449
+ }
450
+ }
431
451
  const { startMCPServer } = await import('./infrastructure/protocols/mcp-server-impl.js');
432
452
  const server = startMCPServer({ port, host });
433
453
  // Wait for the server to either start listening or fail. The default
@@ -597,6 +617,41 @@ program
597
617
  r.end();
598
618
  });
599
619
  });
620
+ // ── migrate-threads — v0.6.0 schema migration sessions.jsonl → projects/<slug>/thread.jsonl
621
+ program
622
+ .command('migrate-threads', { hidden: true }) // hidden from --help (v0.6.0 internal — auto-runs on daemon start once wired)
623
+ .description('Migrate sessions.jsonl into per-project thread.jsonl files (v0.6.0 schema)')
624
+ .option('--source <path>', 'Source sessions.jsonl', `${homedir()}/.codehere/sessions.jsonl`)
625
+ .option('--target <dir>', 'Target projects dir', `${homedir()}/.codehere/projects`)
626
+ .option('--dry-run', 'Report what migrate WOULD do, write nothing', false)
627
+ .option('--rollback', 'Remove the projects dir; sessions.jsonl untouched', false)
628
+ .option('--keep-temp-dirs', 'Include /tmp + /var/folders cwds (default skips them)', false)
629
+ .action(async (options) => {
630
+ const { migrate, dryRun, rollback } = await import('./infrastructure/storage/project-thread-migration.js');
631
+ if (options.rollback) {
632
+ const r = rollback(options.target);
633
+ console.log(`Rollback: removed ${r.removedDirs} project dir(s) from ${options.target}`);
634
+ console.log(`Source ${options.source} untouched.`);
635
+ return;
636
+ }
637
+ const fn = options.dryRun ? dryRun : migrate;
638
+ const label = options.dryRun ? 'DRY RUN' : 'MIGRATION';
639
+ const result = fn({
640
+ sourceJsonl: options.source,
641
+ targetDir: options.target,
642
+ skipTempDirs: !options.keepTempDirs,
643
+ });
644
+ console.log(`=== ${label} ===`);
645
+ console.log(` source: ${options.source}`);
646
+ console.log(` target: ${options.target}`);
647
+ console.log(` sessions processed: ${result.sessionsProcessed}`);
648
+ console.log(` threads ${options.dryRun ? 'would write' : 'written'}: ${result.threadsWritten}`);
649
+ console.log(` threads skipped (exist): ${result.threadsSkippedExisting}`);
650
+ console.log(` unknown-cwd sessions: ${result.unknownCwdSessions}`);
651
+ console.log(` abandoned sessions: ${result.abandonedSessions}`);
652
+ console.log(` temp-dir sessions skipped: ${result.tempDirSessionsSkipped}`);
653
+ console.log(` duration: ${result.durationMs}ms`);
654
+ });
600
655
  // ── scan-tool (called by Claude Code hooks system, not users directly) ───────
601
656
  program
602
657
  .command('scan-tool', { hidden: true }) // hidden from --help (May 2 cut: internal hook handler, called by Claude Code via stdin)
@@ -221,6 +221,12 @@ export function spawnClaudeCode(prompt, options = {}) {
221
221
  }
222
222
  // ── stdout: newline-delimited JSON from `--output-format stream-json` ────────
223
223
  let stdoutBuf = '';
224
+ // Iter 13 — capture the claude-internal session_id from the init event so
225
+ // appendMessage can use `--resume <session-id>` (reliable) instead of `-c`
226
+ // (which picks "most recent in cwd" and can collide with other concurrent
227
+ // claude sessions OR with conversations containing thinking blocks the
228
+ // headless API can't replay).
229
+ let claudeSessionId;
224
230
  child.stdout?.on('data', (chunk) => {
225
231
  stdoutBuf += chunk.toString('utf-8');
226
232
  let idx;
@@ -231,6 +237,12 @@ export function spawnClaudeCode(prompt, options = {}) {
231
237
  continue;
232
238
  try {
233
239
  const data = JSON.parse(line);
240
+ if (claudeSessionId === undefined &&
241
+ data.type === 'system' &&
242
+ data.subtype === 'init' &&
243
+ typeof data.session_id === 'string') {
244
+ claudeSessionId = data.session_id;
245
+ }
234
246
  emit({ type: data.type ?? 'message', timestamp: Date.now(), data });
235
247
  }
236
248
  catch {
@@ -319,20 +331,31 @@ export function spawnClaudeCode(prompt, options = {}) {
319
331
  }, 3000);
320
332
  },
321
333
  /**
322
- * Iter 12 — follow-up message to a completed session. Spawns a fresh
323
- * `claude -p "<text>" -c` in the same cwd, which uses Claude Code's
324
- * native -c flag to continue the most recent conversation in that dir.
334
+ * Iter 12 (with iter-13 fix) — follow-up message to a completed session.
335
+ *
336
+ * Iter 12 used `claude -p "<text>" -c` which picks "most recent
337
+ * conversation in current directory". Iter 13 dogfood proved this
338
+ * fragile: in a cwd with multiple concurrent claude sessions OR with
339
+ * conversations containing thinking blocks, -c either picks the wrong
340
+ * session or hits an API-replay error ("Invalid signature in thinking
341
+ * block"). Fix: capture the claude session_id from this session's init
342
+ * event (above) and use `--resume <session-id>` for follow-ups —
343
+ * unambiguous and replay-safe.
325
344
  *
326
- * Scope: one-shot Q&A. No streaming, no event piping into the parent
327
- * emitter. Returns the assistant's response text. Iter 13 may upgrade
328
- * to a true thread-streaming model if the dogfood signal warrants.
345
+ * Scope: still one-shot Q&A. No streaming. Returns assistant's response
346
+ * text. If we never captured a session_id (init event never fired —
347
+ * shouldn't happen in healthy claude runs), reject explicitly.
329
348
  */
330
349
  appendMessage(text) {
331
350
  const startedAt = Date.now();
332
351
  return new Promise((resolve, reject) => {
352
+ if (!claudeSessionId) {
353
+ reject(new Error('Cannot reply: original claude session_id was never captured (init event missed)'));
354
+ return;
355
+ }
333
356
  let stdout = '';
334
357
  let stderr = '';
335
- const followup = spawn('claude', ['-p', text, '-c', ...HEADLESS_FLAGS], {
358
+ const followup = spawn('claude', ['-p', text, '--resume', claudeSessionId, ...HEADLESS_FLAGS], {
336
359
  cwd,
337
360
  env: spawnEnv,
338
361
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -538,6 +538,11 @@ export function startMCPServer(options = {}) {
538
538
  startedAt: session.startedAt,
539
539
  status: session.status,
540
540
  agent: session.agent,
541
+ // Iter 13 — include canAppendMessage so the freshly-attached
542
+ // session card can correctly enable/disable reply input on the
543
+ // first render. Without this, ALL just-spawned sessions render
544
+ // with reply disabled even when the adapter supports it.
545
+ canAppendMessage: session.canAppendMessage === true,
541
546
  }));
542
547
  }
543
548
  catch (e) {
@@ -1212,7 +1212,7 @@ ${SHARED_STYLES}
1212
1212
  </div>
1213
1213
  </div>
1214
1214
  </div>
1215
- <button class="pane-add" type="button" id="new-project-btn" title="Set a new working folder for the next task">+ New project</button>
1215
+ <!-- intentionally empty: multi-project sidebar arrives in v0.6.x -->
1216
1216
  </aside>
1217
1217
 
1218
1218
  <section class="thread-pane">
@@ -1569,22 +1569,10 @@ ${SHARED_STYLES}
1569
1569
  localStorage.setItem('codehere:cwd', cwdInput.value.trim());
1570
1570
  });
1571
1571
 
1572
- // Iter 12 — "+ New project" button now wired. Previously dead chrome with
1573
- // title="Coming soon". Multi-project sidebar is iter 13+; for now the button
1574
- // does the simplest useful thing: clear the cwd input + scroll-and-focus
1575
- // so the user can type a new folder path. The intake form's existing Assign
1576
- // flow handles spawning the task in that path.
1577
- const newProjectBtn = document.getElementById('new-project-btn');
1578
- if (newProjectBtn) {
1579
- newProjectBtn.addEventListener('click', () => {
1580
- cwdInput.value = '';
1581
- localStorage.removeItem('codehere:cwd');
1582
- // Smooth-scroll the intake form into view (project sidebar is on the left,
1583
- // intake is on the right; on narrow screens the user might be scrolled).
1584
- try { cwdInput.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch { /* old browsers */ }
1585
- cwdInput.focus();
1586
- });
1587
- }
1572
+ // Iter 13 — sidebar 'add project' button + handler removed (was iter 12 work).
1573
+ // The handler cleared cwd input + scrolled + focused, but the label promised
1574
+ // a new sidebar entry that we do not ship until v0.6.x multi-project work.
1575
+ // Mismatch caused real founder confusion in iter-13 tasting. Removed cleanly.
1588
1576
 
1589
1577
  // Gate A1: starter-prompt chips prefill the textarea + focus it so the user
1590
1578
  // can edit or hit ⌘-Enter immediately. No scroll-jumping, no redirect.
@@ -2230,7 +2218,7 @@ ${SHARED_STYLES}
2230
2218
  });
2231
2219
  const data = await res.json();
2232
2220
  if (!res.ok) { alert('Failed: ' + (data.error || res.status)); return; }
2233
- attachSession({ sessionId: data.sessionId, prompt, startedAt: data.startedAt, status: data.status, agent: data.agent || agent || 'conductor' });
2221
+ attachSession({ sessionId: data.sessionId, prompt, startedAt: data.startedAt, status: data.status, agent: data.agent || agent || 'conductor', canAppendMessage: data.canAppendMessage === true });
2234
2222
  promptEl.value = '';
2235
2223
  } catch (e) {
2236
2224
  alert('Network error: ' + (e && e.message ? e.message : e));
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Project thread migration — sessions.jsonl → projects/<slug>/thread.jsonl.
3
+ *
4
+ * v0.6.0 M1+M7 foundation. The new schema groups sessions by their cwd
5
+ * (project) so the persistent project thread shape (one continuous chat per
6
+ * project) has a place to live. The original sessions.jsonl is preserved
7
+ * for one release as back-compat.
8
+ *
9
+ * Built from Spike-1 prototype (agent/spike/v0.6.0-migrate-sessions-to-threads.mjs).
10
+ * Spike validated lossless + reversible on 612 real sessions in 5ms.
11
+ *
12
+ * Production additions over the spike:
13
+ * - Atomic per-project writes (.tmp + rename) so partial failures don't
14
+ * leave half-written thread.jsonl files
15
+ * - Idempotent: skips writing if target thread.jsonl already exists
16
+ * (re-run on daemon start is safe)
17
+ * - Temp-dir filter: skips /tmp/, /var/folders/, /private/var/folders/ by
18
+ * default (configurable) — these are test artifacts, not real projects
19
+ * - Abandoned-session marker: sessions with no completed event get
20
+ * status='abandoned' instead of silent null
21
+ * - Configurable input/output paths so tests can use temp fixtures
22
+ *
23
+ * NOT in scope for this module (handled by callers):
24
+ * - CLI interface (codehere migrate-threads — TBD subcommand)
25
+ * - Auto-run on daemon start
26
+ * - Forward-mirroring (writing to thread.jsonl alongside sessions.jsonl
27
+ * during normal daemon operation)
28
+ *
29
+ * See docs/research/v0.6.0-spike-1-migration.md for the validation history.
30
+ */
31
+ import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync, readdirSync, renameSync } from 'fs';
32
+ import { join } from 'path';
33
+ import { createHash } from 'crypto';
34
+ /** Path patterns considered ephemeral and skipped from project list. */
35
+ const DEFAULT_TEMP_DIR_PATTERNS = [
36
+ /^\/tmp\//,
37
+ /^\/var\/folders\//,
38
+ /^\/private\/var\/folders\//,
39
+ /^\/private\/tmp\//,
40
+ ];
41
+ /** sha1(cwd)[:12] — stable, short, filesystem-safe. */
42
+ function slugForCwd(cwd, unknownLabel) {
43
+ if (!cwd)
44
+ return unknownLabel;
45
+ return createHash('sha1').update(cwd).digest('hex').slice(0, 12);
46
+ }
47
+ function isTempDir(cwd) {
48
+ if (!cwd)
49
+ return false;
50
+ return DEFAULT_TEMP_DIR_PATTERNS.some((re) => re.test(cwd));
51
+ }
52
+ /** Read JSONL events; tolerate malformed lines (count + skip). */
53
+ function readEvents(path) {
54
+ if (!existsSync(path))
55
+ return { events: [], malformed: 0 };
56
+ const text = readFileSync(path, 'utf-8');
57
+ const lines = text.split('\n').filter((l) => l.trim());
58
+ const events = [];
59
+ let malformed = 0;
60
+ for (const line of lines) {
61
+ try {
62
+ events.push(JSON.parse(line));
63
+ }
64
+ catch {
65
+ malformed++;
66
+ }
67
+ }
68
+ return { events, malformed };
69
+ }
70
+ /** Roll up event-log entries into one record per session. */
71
+ function rollupSessions(events) {
72
+ const byId = new Map();
73
+ const completedIds = new Set();
74
+ for (const e of events) {
75
+ const id = (e.id ?? e.sessionId);
76
+ if (!id)
77
+ continue;
78
+ if (e.t === 'completed' || e.completedAt !== undefined)
79
+ completedIds.add(id);
80
+ const existing = byId.get(id) ?? { id, abandoned: true };
81
+ const existingAny = existing;
82
+ for (const k of ['agent', 'prompt', 'cwd', 'startedAt', 'status', 'durationMs', 'completedAt', 'steps', 'artifacts']) {
83
+ if (e[k] !== undefined && existingAny[k] === undefined) {
84
+ existingAny[k] = e[k];
85
+ }
86
+ }
87
+ byId.set(id, existing);
88
+ }
89
+ // Mark abandoned (sessions with no completed event)
90
+ for (const s of byId.values()) {
91
+ s.abandoned = !completedIds.has(s.id);
92
+ if (s.abandoned && !s.status)
93
+ s.status = 'abandoned';
94
+ }
95
+ return [...byId.values()];
96
+ }
97
+ function groupByCwd(sessions, unknownLabel) {
98
+ const groups = new Map();
99
+ for (const s of sessions) {
100
+ const cwd = s.cwd ?? null;
101
+ const slug = slugForCwd(cwd, unknownLabel);
102
+ const group = groups.get(slug) ?? { slug, cwd, sessions: [] };
103
+ group.sessions.push(s);
104
+ groups.set(slug, group);
105
+ }
106
+ return [...groups.values()];
107
+ }
108
+ /** Atomic write: write to .tmp then rename. Caller ensures dir exists. */
109
+ function atomicWriteFile(path, content) {
110
+ const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
111
+ writeFileSync(tmp, content);
112
+ renameSync(tmp, path);
113
+ }
114
+ function buildThreadJsonl(group) {
115
+ const lines = [];
116
+ lines.push(JSON.stringify({
117
+ _meta: 'project',
118
+ slug: group.slug,
119
+ cwd: group.cwd,
120
+ sessionCount: group.sessions.length,
121
+ migratedFrom: 'sessions.jsonl',
122
+ migratedAt: Date.now(),
123
+ }));
124
+ const sorted = [...group.sessions].sort((a, b) => (a.startedAt ?? 0) - (b.startedAt ?? 0));
125
+ for (const s of sorted) {
126
+ lines.push(JSON.stringify({
127
+ type: 'task',
128
+ sessionId: s.id,
129
+ agent: s.agent,
130
+ prompt: s.prompt,
131
+ cwd: s.cwd,
132
+ startedAt: s.startedAt,
133
+ status: s.status,
134
+ durationMs: s.durationMs,
135
+ completedAt: s.completedAt,
136
+ steps: s.steps,
137
+ artifacts: s.artifacts,
138
+ }));
139
+ }
140
+ return lines.join('\n') + '\n';
141
+ }
142
+ /**
143
+ * Run the migration. Idempotent: skips projects whose thread.jsonl already
144
+ * exists (so daemon-startup auto-runs are safe).
145
+ */
146
+ export function migrate(options) {
147
+ const startedAt = Date.now();
148
+ const skipTempDirs = options.skipTempDirs ?? true;
149
+ const unknownLabel = options.unknownCwdLabel ?? 'unknown';
150
+ const { events } = readEvents(options.sourceJsonl);
151
+ const sessions = rollupSessions(events);
152
+ const groups = groupByCwd(sessions, unknownLabel);
153
+ let threadsWritten = 0;
154
+ let threadsSkippedExisting = 0;
155
+ let unknownCwdSessions = 0;
156
+ let abandonedSessions = sessions.filter((s) => s.abandoned).length;
157
+ let tempDirSessionsSkipped = 0;
158
+ for (const group of groups) {
159
+ if (skipTempDirs && isTempDir(group.cwd)) {
160
+ tempDirSessionsSkipped += group.sessions.length;
161
+ continue;
162
+ }
163
+ if (group.cwd === null)
164
+ unknownCwdSessions += group.sessions.length;
165
+ const dir = join(options.targetDir, group.slug);
166
+ const threadFile = join(dir, 'thread.jsonl');
167
+ if (existsSync(threadFile)) {
168
+ threadsSkippedExisting++;
169
+ continue;
170
+ }
171
+ mkdirSync(dir, { recursive: true });
172
+ atomicWriteFile(threadFile, buildThreadJsonl(group));
173
+ threadsWritten++;
174
+ }
175
+ return {
176
+ sessionsProcessed: sessions.length,
177
+ threadsWritten,
178
+ threadsSkippedExisting,
179
+ unknownCwdSessions,
180
+ abandonedSessions,
181
+ tempDirSessionsSkipped,
182
+ durationMs: Date.now() - startedAt,
183
+ };
184
+ }
185
+ /** Dry-run: same as migrate but writes nothing. Returns what WOULD happen. */
186
+ export function dryRun(options) {
187
+ const startedAt = Date.now();
188
+ const skipTempDirs = options.skipTempDirs ?? true;
189
+ const unknownLabel = options.unknownCwdLabel ?? 'unknown';
190
+ const { events } = readEvents(options.sourceJsonl);
191
+ const sessions = rollupSessions(events);
192
+ const groups = groupByCwd(sessions, unknownLabel);
193
+ let threadsWritten = 0;
194
+ let threadsSkippedExisting = 0;
195
+ let unknownCwdSessions = 0;
196
+ let abandonedSessions = sessions.filter((s) => s.abandoned).length;
197
+ let tempDirSessionsSkipped = 0;
198
+ for (const group of groups) {
199
+ if (skipTempDirs && isTempDir(group.cwd)) {
200
+ tempDirSessionsSkipped += group.sessions.length;
201
+ continue;
202
+ }
203
+ if (group.cwd === null)
204
+ unknownCwdSessions += group.sessions.length;
205
+ const threadFile = join(options.targetDir, group.slug, 'thread.jsonl');
206
+ if (existsSync(threadFile))
207
+ threadsSkippedExisting++;
208
+ else
209
+ threadsWritten++;
210
+ }
211
+ return {
212
+ sessionsProcessed: sessions.length,
213
+ threadsWritten,
214
+ threadsSkippedExisting,
215
+ unknownCwdSessions,
216
+ abandonedSessions,
217
+ tempDirSessionsSkipped,
218
+ durationMs: Date.now() - startedAt,
219
+ };
220
+ }
221
+ /**
222
+ * Rollback: removes the projects directory. Source sessions.jsonl is never
223
+ * touched. Returns the count of removed project subdirectories.
224
+ */
225
+ export function rollback(targetDir) {
226
+ if (!existsSync(targetDir))
227
+ return { removedDirs: 0 };
228
+ const subdirs = readdirSync(targetDir);
229
+ rmSync(targetDir, { recursive: true });
230
+ return { removedDirs: subdirs.length };
231
+ }
232
+ //# sourceMappingURL=project-thread-migration.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codehere",
3
- "version": "0.5.0-alpha.3",
3
+ "version": "0.5.0-alpha.4",
4
4
  "description": "A PM for your AI coding agents. Delegate, orchestrate, and audit Claude Code, Codex, Aider, OpenCode, Gemini, and any user-configured CLI agent from one local web UI — every AI action traced, every file scored, every spawn warmed with project memory.",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",