tmux-team 2.1.0 → 2.2.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.
@@ -40,7 +40,7 @@ describe('FSAdapter', () => {
40
40
  expect(team.name).toBe('My Project');
41
41
  expect(team.windowId).toBe('window-1');
42
42
  expect(team.id).toBe(path.basename(testDir));
43
- expect(team.createdAt).toBeDefined();
43
+ expect(team.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
44
44
 
45
45
  // Verify file contents
46
46
  const teamFile = path.join(testDir, 'team.json');
@@ -162,6 +162,80 @@ describe('FSAdapter', () => {
162
162
  const milestone = await adapter.getMilestone('1');
163
163
  expect(milestone).toBeNull();
164
164
  });
165
+
166
+ it('creates markdown doc file for milestone', async () => {
167
+ const milestone = await adapter.createMilestone({ name: 'Phase 1' });
168
+ const docPath = path.join(testDir, 'milestones', `${milestone.id}.md`);
169
+
170
+ expect(fs.existsSync(docPath)).toBe(true);
171
+ const content = fs.readFileSync(docPath, 'utf-8');
172
+ expect(content).toContain('# Phase 1');
173
+ });
174
+
175
+ it('creates milestone with description in doc file', async () => {
176
+ const milestone = await adapter.createMilestone({
177
+ name: 'Phase 1',
178
+ description: 'Initial development phase',
179
+ });
180
+ const docPath = path.join(testDir, 'milestones', `${milestone.id}.md`);
181
+
182
+ const content = fs.readFileSync(docPath, 'utf-8');
183
+ expect(content).toContain('# Phase 1');
184
+ expect(content).toContain('Initial development phase');
185
+ });
186
+
187
+ it('deletes both json and md files on milestone delete', async () => {
188
+ const milestone = await adapter.createMilestone({ name: 'Cleanup' });
189
+ const jsonPath = path.join(testDir, 'milestones', `${milestone.id}.json`);
190
+ const mdPath = path.join(testDir, 'milestones', `${milestone.id}.md`);
191
+
192
+ expect(fs.existsSync(jsonPath)).toBe(true);
193
+ expect(fs.existsSync(mdPath)).toBe(true);
194
+
195
+ await adapter.deleteMilestone(milestone.id);
196
+
197
+ expect(fs.existsSync(jsonPath)).toBe(false);
198
+ expect(fs.existsSync(mdPath)).toBe(false);
199
+ });
200
+ });
201
+
202
+ describe('Milestone Documentation', () => {
203
+ beforeEach(async () => {
204
+ await adapter.initTeam('Test Project');
205
+ });
206
+
207
+ it('gets milestone documentation', async () => {
208
+ await adapter.createMilestone({ name: 'Phase 1', description: 'Intro' });
209
+ const doc = await adapter.getMilestoneDoc('1');
210
+
211
+ expect(doc).toContain('# Phase 1');
212
+ expect(doc).toContain('Intro');
213
+ });
214
+
215
+ it('sets milestone documentation', async () => {
216
+ await adapter.createMilestone({ name: 'Test' });
217
+ await adapter.setMilestoneDoc('1', '# Updated\n\nNew content');
218
+
219
+ const doc = await adapter.getMilestoneDoc('1');
220
+ expect(doc).toBe('# Updated\n\nNew content');
221
+ });
222
+
223
+ it('creates milestones dir when setting doc if missing', async () => {
224
+ await adapter.createMilestone({ name: 'Test' });
225
+ // Remove the milestones dir
226
+ fs.rmSync(path.join(testDir, 'milestones'), { recursive: true, force: true });
227
+
228
+ await adapter.setMilestoneDoc('1', '# Updated\n\nBody');
229
+
230
+ expect(fs.existsSync(path.join(testDir, 'milestones', '1.md'))).toBe(true);
231
+ const doc = await adapter.getMilestoneDoc('1');
232
+ expect(doc).toBe('# Updated\n\nBody');
233
+ });
234
+
235
+ it('returns null for non-existent doc', async () => {
236
+ const doc = await adapter.getMilestoneDoc('999');
237
+ expect(doc).toBeNull();
238
+ });
165
239
  });
166
240
 
167
241
  describe('Task Operations', () => {
@@ -254,6 +328,60 @@ describe('FSAdapter', () => {
254
328
  expect(tasks[0].assignee).toBe('claude');
255
329
  });
256
330
 
331
+ it('excludes tasks in completed milestones by default', async () => {
332
+ // Create milestones
333
+ const m1 = await adapter.createMilestone({ name: 'Done Milestone' });
334
+ const m2 = await adapter.createMilestone({ name: 'Open Milestone' });
335
+ await adapter.updateMilestone(m1.id, { status: 'done' });
336
+
337
+ // Create tasks in both milestones
338
+ await adapter.createTask({ title: 'Task in done milestone', milestone: m1.id });
339
+ await adapter.createTask({ title: 'Task in open milestone', milestone: m2.id });
340
+ await adapter.createTask({ title: 'Task without milestone' });
341
+
342
+ // Default: excludeCompletedMilestones = true
343
+ const tasks = await adapter.listTasks();
344
+ expect(tasks).toHaveLength(2);
345
+ expect(tasks.map((t) => t.title)).not.toContain('Task in done milestone');
346
+ });
347
+
348
+ it('includes tasks in completed milestones when excludeCompletedMilestones is false', async () => {
349
+ // Create milestones
350
+ const m1 = await adapter.createMilestone({ name: 'Done Milestone' });
351
+ await adapter.updateMilestone(m1.id, { status: 'done' });
352
+
353
+ // Create task in done milestone
354
+ await adapter.createTask({ title: 'Task in done milestone', milestone: m1.id });
355
+ await adapter.createTask({ title: 'Task without milestone' });
356
+
357
+ // Include all tasks
358
+ const tasks = await adapter.listTasks({ excludeCompletedMilestones: false });
359
+ expect(tasks).toHaveLength(2);
360
+ expect(tasks.map((t) => t.title)).toContain('Task in done milestone');
361
+ });
362
+
363
+ it('hides tasks without milestones when hideOrphanTasks is true', async () => {
364
+ const milestone = await adapter.createMilestone({ name: 'Has Tasks' });
365
+
366
+ await adapter.createTask({ title: 'With milestone', milestone: milestone.id });
367
+ await adapter.createTask({ title: 'Without milestone' });
368
+
369
+ const tasks = await adapter.listTasks({ hideOrphanTasks: true });
370
+ expect(tasks).toHaveLength(1);
371
+ expect(tasks[0].title).toBe('With milestone');
372
+ });
373
+
374
+ it('includes tasks without milestones when hideOrphanTasks is false', async () => {
375
+ const milestone = await adapter.createMilestone({ name: 'Has Tasks' });
376
+
377
+ await adapter.createTask({ title: 'With milestone', milestone: milestone.id });
378
+ await adapter.createTask({ title: 'Without milestone' });
379
+
380
+ const tasks = await adapter.listTasks({ hideOrphanTasks: false });
381
+ expect(tasks).toHaveLength(2);
382
+ expect(tasks.map((t) => t.title)).toContain('Without milestone');
383
+ });
384
+
257
385
  it('returns task by ID', async () => {
258
386
  await adapter.createTask({ title: 'Test Task' });
259
387
  const task = await adapter.getTask('1');
@@ -106,10 +106,17 @@ export class FSAdapter implements StorageAdapter {
106
106
  id,
107
107
  name: input.name,
108
108
  status: 'pending',
109
+ description: input.description,
110
+ docPath: `milestones/${id}.md`,
109
111
  createdAt: this.now(),
110
112
  updatedAt: this.now(),
111
113
  };
112
114
  this.writeJson(path.join(this.milestonesDir, `${id}.json`), milestone);
115
+ // Create doc file with name and optional description
116
+ const docContent = input.description
117
+ ? `# ${input.name}\n\n${input.description}\n`
118
+ : `# ${input.name}\n\n`;
119
+ fs.writeFileSync(path.join(this.milestonesDir, `${id}.md`), docContent);
113
120
  return milestone;
114
121
  }
115
122
 
@@ -141,10 +148,10 @@ export class FSAdapter implements StorageAdapter {
141
148
  }
142
149
 
143
150
  async deleteMilestone(id: string): Promise<void> {
144
- const filePath = path.join(this.milestonesDir, `${id}.json`);
145
- if (fs.existsSync(filePath)) {
146
- fs.unlinkSync(filePath);
147
- }
151
+ const jsonPath = path.join(this.milestonesDir, `${id}.json`);
152
+ const mdPath = path.join(this.milestonesDir, `${id}.md`);
153
+ if (fs.existsSync(jsonPath)) fs.unlinkSync(jsonPath);
154
+ if (fs.existsSync(mdPath)) fs.unlinkSync(mdPath);
148
155
  }
149
156
 
150
157
  // Task operations
@@ -191,6 +198,21 @@ export class FSAdapter implements StorageAdapter {
191
198
  tasks = tasks.filter((t) => t.assignee === filter.assignee);
192
199
  }
193
200
 
201
+ // Exclude tasks in completed milestones (default: true)
202
+ const excludeCompleted = filter?.excludeCompletedMilestones ?? true;
203
+ if (excludeCompleted) {
204
+ const milestones = await this.listMilestones();
205
+ const completedMilestoneIds = new Set(
206
+ milestones.filter((m) => m.status === 'done').map((m) => m.id)
207
+ );
208
+ tasks = tasks.filter((t) => !t.milestone || !completedMilestoneIds.has(t.milestone));
209
+ }
210
+
211
+ // Hide tasks without milestone if configured
212
+ if (filter?.hideOrphanTasks) {
213
+ tasks = tasks.filter((t) => t.milestone);
214
+ }
215
+
194
216
  return tasks.sort((a, b) => parseInt(a.id) - parseInt(b.id));
195
217
  }
196
218
 
@@ -225,6 +247,18 @@ export class FSAdapter implements StorageAdapter {
225
247
  fs.writeFileSync(docPath, content);
226
248
  }
227
249
 
250
+ async getMilestoneDoc(id: string): Promise<string | null> {
251
+ const docPath = path.join(this.milestonesDir, `${id}.md`);
252
+ if (!fs.existsSync(docPath)) return null;
253
+ return fs.readFileSync(docPath, 'utf-8');
254
+ }
255
+
256
+ async setMilestoneDoc(id: string, content: string): Promise<void> {
257
+ this.ensureDir(this.milestonesDir);
258
+ const docPath = path.join(this.milestonesDir, `${id}.md`);
259
+ fs.writeFileSync(docPath, content);
260
+ }
261
+
228
262
  // Audit log
229
263
  async appendEvent(event: AuditEvent): Promise<void> {
230
264
  this.ensureDir(this.teamDir);
@@ -53,6 +53,7 @@ interface GHIssue {
53
53
  interface GHMilestone {
54
54
  number: number;
55
55
  title: string;
56
+ description: string | null;
56
57
  state: 'open' | 'closed'; // REST API uses lowercase (unlike GraphQL)
57
58
  createdAt: string;
58
59
  }
@@ -61,13 +62,13 @@ interface GHMilestone {
61
62
  // Local cache for ID mapping (task ID -> issue number)
62
63
  // ─────────────────────────────────────────────────────────────
63
64
 
64
- const CACHE_VERSION = 2;
65
+ const CACHE_VERSION = 3; // Bumped: milestone state now cached
65
66
 
66
67
  interface IdCache {
67
68
  version: number; // Cache format version for migrations
68
69
  repo: string; // Associated repo to detect cross-repo drift
69
70
  tasks: Record<string, number>; // task ID -> issue number
70
- milestones: Record<string, { number: number; name: string }>; // milestone ID -> {number, name}
71
+ milestones: Record<string, { number: number; name: string; state?: 'open' | 'closed' }>; // milestone ID -> {number, name, state}
71
72
  nextTaskId: number;
72
73
  nextMilestoneId: number;
73
74
  }
@@ -89,7 +90,7 @@ export class GitHubAdapter implements StorageAdapter {
89
90
  // Helper: Execute gh CLI command safely (no shell injection)
90
91
  // ─────────────────────────────────────────────────────────────
91
92
 
92
- private gh(args: string[], options?: { skipRepo?: boolean }): string {
93
+ private gh(args: string[], options?: { skipRepo?: boolean; input?: string }): string {
93
94
  const fullArgs = [...args];
94
95
  // gh api doesn't accept --repo flag (repo is in the endpoint path)
95
96
  // Other commands like 'gh issue' do accept --repo
@@ -101,6 +102,7 @@ export class GitHubAdapter implements StorageAdapter {
101
102
  const result = spawnSync('gh', fullArgs, {
102
103
  encoding: 'utf-8',
103
104
  stdio: ['pipe', 'pipe', 'pipe'],
105
+ input: options?.input,
104
106
  });
105
107
 
106
108
  if (result.error) {
@@ -137,9 +139,17 @@ export class GitHubAdapter implements StorageAdapter {
137
139
  try {
138
140
  const data = JSON.parse(fs.readFileSync(this.cacheFile, 'utf-8')) as IdCache;
139
141
 
142
+ // Migrate from version 2 to 3: add milestone state field
143
+ if (data.version === 2) {
144
+ // Preserve all existing data, just update version
145
+ // Milestone states will be populated on next listMilestones() call
146
+ data.version = 3;
147
+ this.saveCache(data);
148
+ }
149
+
140
150
  // Validate cache integrity
141
151
  if (data.version !== CACHE_VERSION) {
142
- // Version mismatch - reset cache (future: migration logic)
152
+ // Unknown version - reset cache
143
153
  console.error(`[tmux-team] Cache version mismatch, resetting cache`);
144
154
  return this.createEmptyCache();
145
155
  }
@@ -254,6 +264,8 @@ export class GitHubAdapter implements StorageAdapter {
254
264
  id,
255
265
  name: ghMilestone.title,
256
266
  status: ghMilestone.state === 'closed' ? 'done' : 'pending',
267
+ description: ghMilestone.description || undefined,
268
+ docPath: `github:milestone/${ghMilestone.number}`,
257
269
  createdAt: ghMilestone.createdAt,
258
270
  updatedAt: ghMilestone.createdAt, // GH milestones don't have updatedAt
259
271
  };
@@ -348,7 +360,7 @@ export class GitHubAdapter implements StorageAdapter {
348
360
 
349
361
  async createMilestone(input: CreateMilestoneInput): Promise<Milestone> {
350
362
  // Create milestone in GitHub
351
- const result = this.gh([
363
+ const args = [
352
364
  'api',
353
365
  `repos/${this.repo}/milestones`,
354
366
  '-X',
@@ -357,19 +369,30 @@ export class GitHubAdapter implements StorageAdapter {
357
369
  `title=${input.name}`,
358
370
  '-f',
359
371
  'state=open',
360
- ]);
361
- const ghMilestone = JSON.parse(result) as { number: number; title: string; created_at: string };
372
+ ];
373
+ if (input.description) {
374
+ args.push('-f', `description=${input.description}`);
375
+ }
376
+ const result = this.gh(args);
377
+ const ghMilestone = JSON.parse(result) as {
378
+ number: number;
379
+ title: string;
380
+ description: string | null;
381
+ created_at: string;
382
+ };
362
383
 
363
- // Cache the ID mapping (store both number and name)
384
+ // Cache the ID mapping (store number, name, and state)
364
385
  const cache = this.loadCache();
365
386
  const id = String(cache.nextMilestoneId++);
366
- cache.milestones[id] = { number: ghMilestone.number, name: ghMilestone.title };
387
+ cache.milestones[id] = { number: ghMilestone.number, name: ghMilestone.title, state: 'open' };
367
388
  this.saveCache(cache);
368
389
 
369
390
  return {
370
391
  id,
371
392
  name: ghMilestone.title,
372
393
  status: 'pending',
394
+ description: ghMilestone.description || undefined,
395
+ docPath: `github:milestone/${ghMilestone.number}`,
373
396
  createdAt: ghMilestone.created_at,
374
397
  updatedAt: ghMilestone.created_at,
375
398
  };
@@ -416,8 +439,9 @@ export class GitHubAdapter implements StorageAdapter {
416
439
  if (!id) {
417
440
  // New milestone from GitHub, assign ID
418
441
  id = String(cache.nextMilestoneId++);
419
- cache.milestones[id] = { number: ghm.number, name: ghm.title };
420
442
  }
443
+ // Always update cache with current state
444
+ cache.milestones[id] = { number: ghm.number, name: ghm.title, state: ghm.state };
421
445
  milestones.push(this.milestoneToMilestone(ghm, id));
422
446
  }
423
447
 
@@ -443,9 +467,24 @@ export class GitHubAdapter implements StorageAdapter {
443
467
  if (input.status) {
444
468
  args.push('-f', `state=${input.status === 'done' ? 'closed' : 'open'}`);
445
469
  }
470
+ if (input.description !== undefined) {
471
+ args.push('-f', `description=${input.description}`);
472
+ }
446
473
 
447
474
  const result = this.gh(args);
448
475
  const ghMilestone = JSON.parse(result) as GHMilestone;
476
+
477
+ // Update cache with new name/state
478
+ const cache = this.loadCache();
479
+ if (cache.milestones[id]) {
480
+ cache.milestones[id] = {
481
+ ...cache.milestones[id],
482
+ name: ghMilestone.title,
483
+ state: ghMilestone.state,
484
+ };
485
+ this.saveCache(cache);
486
+ }
487
+
449
488
  return this.milestoneToMilestone(ghMilestone, id);
450
489
  }
451
490
 
@@ -453,13 +492,12 @@ export class GitHubAdapter implements StorageAdapter {
453
492
  const number = this.getMilestoneNumber(id);
454
493
  if (!number) return;
455
494
 
456
- try {
457
- this.gh(['api', `repos/${this.repo}/milestones/${number}`, '-X', 'DELETE']);
458
- } catch {
459
- // Already deleted or not found
460
- }
495
+ // Note: We intentionally don't delete the milestone from GitHub.
496
+ // GitHub milestones may be used by other tools, and deletion is destructive.
497
+ // Instead, we only remove it from the local cache (soft delete).
498
+ // To permanently delete, use the GitHub web UI or `gh api` directly.
461
499
 
462
- // Remove from cache
500
+ // Remove from cache only
463
501
  const cache = this.loadCache();
464
502
  delete cache.milestones[id];
465
503
  this.saveCache(cache);
@@ -580,7 +618,7 @@ export class GitHubAdapter implements StorageAdapter {
580
618
 
581
619
  try {
582
620
  const issues = this.ghJson<GHIssue[]>(args);
583
- const tasks: Task[] = [];
621
+ let tasks: Task[] = [];
584
622
 
585
623
  for (const issue of issues) {
586
624
  // Find or create ID mapping
@@ -593,6 +631,23 @@ export class GitHubAdapter implements StorageAdapter {
593
631
  tasks.push(this.issueToTask(issue, id, cache));
594
632
  }
595
633
 
634
+ // Exclude tasks in completed milestones (default: true)
635
+ const excludeCompleted = filter?.excludeCompletedMilestones ?? true;
636
+ if (excludeCompleted) {
637
+ // Build set of closed milestone IDs from cache
638
+ const closedMilestoneIds = new Set(
639
+ Object.entries(cache.milestones)
640
+ .filter(([, m]) => m.state === 'closed')
641
+ .map(([id]) => id)
642
+ );
643
+ tasks = tasks.filter((t) => !t.milestone || !closedMilestoneIds.has(t.milestone));
644
+ }
645
+
646
+ // Hide tasks without milestone if configured
647
+ if (filter?.hideOrphanTasks) {
648
+ tasks = tasks.filter((t) => t.milestone);
649
+ }
650
+
596
651
  this.saveCache(cache);
597
652
  return tasks.sort((a, b) => parseInt(a.id) - parseInt(b.id));
598
653
  } catch (error) {
@@ -705,6 +760,30 @@ export class GitHubAdapter implements StorageAdapter {
705
760
  this.gh(['issue', 'edit', String(number), '--body', content]);
706
761
  }
707
762
 
763
+ async getMilestoneDoc(id: string): Promise<string | null> {
764
+ const number = this.getMilestoneNumber(id);
765
+ if (!number) return null;
766
+
767
+ try {
768
+ const result = this.gh(['api', `repos/${this.repo}/milestones/${number}`]);
769
+ const ghMilestone = JSON.parse(result) as GHMilestone;
770
+ return ghMilestone.description || null;
771
+ } catch {
772
+ return null;
773
+ }
774
+ }
775
+
776
+ async setMilestoneDoc(id: string, content: string): Promise<void> {
777
+ const number = this.getMilestoneNumber(id);
778
+ if (!number) throw new Error(`Milestone ${id} not found`);
779
+
780
+ // Use -F with @- to read from stdin (handles multiline content properly)
781
+ this.gh(
782
+ ['api', `repos/${this.repo}/milestones/${number}`, '-X', 'PATCH', '-F', 'description=@-'],
783
+ { input: content }
784
+ );
785
+ }
786
+
708
787
  // ─────────────────────────────────────────────────────────────
709
788
  // Audit log (Issue Comments)
710
789
  // ─────────────────────────────────────────────────────────────
package/src/pm/types.ts CHANGED
@@ -16,6 +16,8 @@ export interface Milestone {
16
16
  id: string;
17
17
  name: string;
18
18
  status: MilestoneStatus;
19
+ description?: string;
20
+ docPath?: string;
19
21
  createdAt: string;
20
22
  updatedAt: string;
21
23
  }
@@ -55,17 +57,21 @@ export interface UpdateTaskInput {
55
57
 
56
58
  export interface CreateMilestoneInput {
57
59
  name: string;
60
+ description?: string;
58
61
  }
59
62
 
60
63
  export interface UpdateMilestoneInput {
61
64
  name?: string;
62
65
  status?: MilestoneStatus;
66
+ description?: string;
63
67
  }
64
68
 
65
69
  export interface ListTasksFilter {
66
70
  milestone?: string;
67
71
  status?: TaskStatus;
68
72
  assignee?: string;
73
+ excludeCompletedMilestones?: boolean; // Hide tasks in completed milestones (default: true)
74
+ hideOrphanTasks?: boolean; // Hide tasks without milestone (default: false)
69
75
  }
70
76
 
71
77
  // ─────────────────────────────────────────────────────────────
package/src/state.test.ts CHANGED
@@ -51,8 +51,11 @@ describe('State Management', () => {
51
51
  fs.writeFileSync(paths.stateFile, JSON.stringify(existingState));
52
52
 
53
53
  const state = loadState(paths);
54
- expect(state.requests.claude).toBeDefined();
55
- expect(state.requests.claude?.nonce).toBe('abc123');
54
+ expect(state.requests.claude).toMatchObject({
55
+ id: 'req-1',
56
+ nonce: 'abc123',
57
+ pane: '1.0',
58
+ });
56
59
  });
57
60
 
58
61
  it('returns empty state when state.json is corrupted', () => {
@@ -123,7 +126,11 @@ describe('State Management', () => {
123
126
 
124
127
  const cleaned = cleanupState(paths, 60); // 60 second TTL
125
128
 
126
- expect(cleaned.requests.recentAgent).toBeDefined();
129
+ expect(cleaned.requests.recentAgent).toMatchObject({
130
+ id: 'new',
131
+ nonce: 'new',
132
+ pane: '1.0',
133
+ });
127
134
  });
128
135
 
129
136
  it('requires ttlSeconds parameter', () => {
@@ -142,7 +149,7 @@ describe('State Management', () => {
142
149
  // With 10s TTL, agent1 should be kept, agent2 removed
143
150
  const cleaned = cleanupState(paths, 10);
144
151
 
145
- expect(cleaned.requests.agent1).toBeDefined();
152
+ expect(cleaned.requests.agent1).toMatchObject({ id: '1', nonce: 'a' });
146
153
  expect(cleaned.requests.agent2).toBeUndefined();
147
154
  });
148
155
 
@@ -193,8 +200,11 @@ describe('State Management', () => {
193
200
  setActiveRequest(paths, 'claude', req);
194
201
 
195
202
  const state = loadState(paths);
196
- expect(state.requests.claude).toBeDefined();
197
- expect(state.requests.claude?.id).toBe('req-1');
203
+ expect(state.requests.claude).toMatchObject({
204
+ id: 'req-1',
205
+ nonce: 'nonce123',
206
+ pane: '1.0',
207
+ });
198
208
  });
199
209
 
200
210
  it('stores request with id, nonce, pane, and startedAtMs', () => {
@@ -244,8 +254,8 @@ describe('State Management', () => {
244
254
  setActiveRequest(paths, 'codex', req2);
245
255
 
246
256
  const state = loadState(paths);
247
- expect(state.requests.claude).toBeDefined();
248
- expect(state.requests.codex).toBeDefined();
257
+ expect(state.requests.claude).toMatchObject({ id: '1', nonce: 'a' });
258
+ expect(state.requests.codex).toMatchObject({ id: '2', nonce: 'b' });
249
259
  });
250
260
  });
251
261
 
@@ -282,7 +292,7 @@ describe('State Management', () => {
282
292
  clearActiveRequest(paths, 'claude', 'wrong-id');
283
293
 
284
294
  const state = loadState(paths);
285
- expect(state.requests.claude).toBeDefined(); // Should still exist
295
+ expect(state.requests.claude).toMatchObject({ id: 'req-1', nonce: 'a' }); // Should still exist
286
296
  });
287
297
 
288
298
  it('clears when requestId matches', () => {
@@ -305,7 +315,7 @@ describe('State Management', () => {
305
315
 
306
316
  const state = loadState(paths);
307
317
  expect(state.requests.claude).toBeUndefined();
308
- expect(state.requests.codex).toBeDefined();
318
+ expect(state.requests.codex).toMatchObject({ id: '2', nonce: 'b' });
309
319
  });
310
320
  });
311
321
  });
package/src/state.ts CHANGED
@@ -15,6 +15,7 @@ export interface AgentRequestState {
15
15
 
16
16
  export interface StateFile {
17
17
  requests: Record<string, AgentRequestState>;
18
+ preambleCounters?: Record<string, number>; // agentName -> message count
18
19
  }
19
20
 
20
21
  const DEFAULT_STATE: StateFile = { requests: {} };
@@ -52,7 +53,10 @@ export function cleanupState(paths: Paths, ttlSeconds: number): StateFile {
52
53
  const now = Date.now();
53
54
 
54
55
  const ttlMs = Math.max(1, ttlSeconds) * 1000;
55
- const next: StateFile = { requests: {} };
56
+ const next: StateFile = {
57
+ requests: {},
58
+ preambleCounters: state.preambleCounters, // Preserve preamble counters
59
+ };
56
60
 
57
61
  for (const [agent, req] of Object.entries(state.requests)) {
58
62
  if (!req || typeof req.startedAtMs !== 'number') continue;
@@ -81,3 +85,26 @@ export function clearActiveRequest(paths: Paths, agent: string, requestId?: stri
81
85
  delete state.requests[agent];
82
86
  saveState(paths, state);
83
87
  }
88
+
89
+ /**
90
+ * Get the current preamble counter for an agent.
91
+ * Returns 0 if not set.
92
+ */
93
+ export function getPreambleCounter(paths: Paths, agent: string): number {
94
+ const state = loadState(paths);
95
+ return state.preambleCounters?.[agent] ?? 0;
96
+ }
97
+
98
+ /**
99
+ * Increment the preamble counter for an agent and return the new value.
100
+ */
101
+ export function incrementPreambleCounter(paths: Paths, agent: string): number {
102
+ const state = loadState(paths);
103
+ if (!state.preambleCounters) {
104
+ state.preambleCounters = {};
105
+ }
106
+ const newCount = (state.preambleCounters[agent] ?? 0) + 1;
107
+ state.preambleCounters[agent] = newCount;
108
+ saveState(paths, state);
109
+ return newCount;
110
+ }
package/src/types.ts CHANGED
@@ -10,24 +10,28 @@ export interface AgentConfig {
10
10
  export interface PaneEntry {
11
11
  pane: string;
12
12
  remark?: string;
13
+ preamble?: string; // Agent preamble (prepended to messages)
14
+ deny?: string[]; // Permission deny patterns
13
15
  }
14
16
 
15
17
  export interface ConfigDefaults {
16
18
  timeout: number; // seconds
17
19
  pollInterval: number; // seconds
18
20
  captureLines: number;
21
+ preambleEvery: number; // inject preamble every N messages (default: 3)
22
+ hideOrphanTasks: boolean; // hide tasks without milestone in list (default: false)
19
23
  }
20
24
 
21
25
  export interface GlobalConfig {
22
26
  mode: 'polling' | 'wait';
23
27
  preambleMode: 'always' | 'disabled';
24
28
  defaults: ConfigDefaults;
25
- agents: Record<string, AgentConfig>;
26
29
  }
27
30
 
28
31
  export interface LocalSettings {
29
32
  mode?: 'polling' | 'wait';
30
33
  preambleMode?: 'always' | 'disabled';
34
+ preambleEvery?: number; // local override for preamble frequency
31
35
  }
32
36
 
33
37
  export interface LocalConfigFile {
package/src/ui.ts CHANGED
@@ -7,7 +7,8 @@ import type { UI } from './types.js';
7
7
  const isTTY = process.stdout.isTTY;
8
8
 
9
9
  // Strip ANSI escape codes for accurate length calculation
10
- const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, '');
10
+ const ansiEscape = String.fromCharCode(27);
11
+ const stripAnsi = (s: string) => s.replace(new RegExp(`${ansiEscape}\\[[0-9;]*m`, 'g'), '');
11
12
 
12
13
  export const colors = {
13
14
  red: (s: string) => (isTTY ? `\x1b[31m${s}\x1b[0m` : s),