tmux-team 2.0.0-alpha.1 → 2.0.0-alpha.3

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.
@@ -0,0 +1,278 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // Permission system for PM commands
3
+ // ─────────────────────────────────────────────────────────────
4
+
5
+ import { execSync } from 'child_process';
6
+ import type { ResolvedConfig, PaneEntry } from '../types.js';
7
+
8
+ /**
9
+ * Permission expression format:
10
+ * pm:<resource>:<action>(<field1>,<field2>,...)
11
+ *
12
+ * Examples:
13
+ * - pm:task:list
14
+ * - pm:task:update(status)
15
+ * - pm:task:update(assignee,status)
16
+ * - pm:task:update(*) - wildcard, any field
17
+ * - pm:task:update - no fields, entire action
18
+ */
19
+
20
+ export interface PermissionCheck {
21
+ resource: string; // task, milestone, team, doc, log
22
+ action: string; // list, show, create, update, read
23
+ fields: string[]; // sorted alphabetically
24
+ }
25
+
26
+ /**
27
+ * Build permission path from command context.
28
+ */
29
+ export function buildPermissionPath(check: PermissionCheck): string {
30
+ const base = `pm:${check.resource}:${check.action}`;
31
+ if (check.fields.length === 0) {
32
+ return base;
33
+ }
34
+ // Sort fields alphabetically for canonical form
35
+ const sortedFields = [...check.fields].sort();
36
+ return `${base}(${sortedFields.join(',')})`;
37
+ }
38
+
39
+ /**
40
+ * Parse a deny pattern into its components.
41
+ */
42
+ function parsePattern(pattern: string): {
43
+ resource: string;
44
+ action: string;
45
+ fields: string[] | '*' | null;
46
+ } {
47
+ // Pattern format: pm:resource:action or pm:resource:action(fields) or pm:resource:action(*)
48
+ const match = pattern.match(/^pm:(\w+):(\w+)(?:\(([^)]*)\))?$/);
49
+ if (!match) {
50
+ return { resource: '', action: '', fields: null };
51
+ }
52
+
53
+ const [, resource, action, fieldsStr] = match;
54
+
55
+ if (fieldsStr === undefined) {
56
+ // No parentheses - blocks entire action
57
+ return { resource, action, fields: null };
58
+ }
59
+
60
+ if (fieldsStr === '*') {
61
+ // Wildcard - blocks any field
62
+ return { resource, action, fields: '*' };
63
+ }
64
+
65
+ // Specific fields
66
+ const fields = fieldsStr
67
+ .split(',')
68
+ .map((f) => f.trim())
69
+ .filter(Boolean);
70
+ return { resource, action, fields };
71
+ }
72
+
73
+ /**
74
+ * Check if a permission path matches a deny pattern.
75
+ */
76
+ function matchesPattern(path: PermissionCheck, pattern: string): boolean {
77
+ const parsed = parsePattern(pattern);
78
+
79
+ // Resource and action must match
80
+ if (parsed.resource !== path.resource || parsed.action !== path.action) {
81
+ return false;
82
+ }
83
+
84
+ // No fields in pattern = block entire action
85
+ if (parsed.fields === null) {
86
+ return true;
87
+ }
88
+
89
+ // Wildcard = block if path has any fields
90
+ if (parsed.fields === '*') {
91
+ return path.fields.length > 0;
92
+ }
93
+
94
+ // Specific fields = block if path uses ANY of the denied fields
95
+ return parsed.fields.some((f) => path.fields.includes(f));
96
+ }
97
+
98
+ /**
99
+ * Get current tmux pane in "window.pane" format.
100
+ * Returns null if not running in tmux.
101
+ */
102
+ function getCurrentPane(): string | null {
103
+ // Check if we're in tmux
104
+ if (!process.env.TMUX) {
105
+ return null;
106
+ }
107
+
108
+ try {
109
+ const result = execSync("tmux display-message -p '#{window_index}.#{pane_index}'", {
110
+ encoding: 'utf-8',
111
+ timeout: 1000,
112
+ }).trim();
113
+ return result || null;
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Look up agent name by pane ID in the registry.
121
+ */
122
+ function findAgentByPane(paneRegistry: Record<string, PaneEntry>, paneId: string): string | null {
123
+ for (const [agentName, entry] of Object.entries(paneRegistry)) {
124
+ if (entry.pane === paneId) {
125
+ return agentName;
126
+ }
127
+ }
128
+ return null;
129
+ }
130
+
131
+ export interface ActorResolution {
132
+ actor: string;
133
+ source: 'pane' | 'env' | 'default';
134
+ warning?: string;
135
+ }
136
+
137
+ /**
138
+ * Resolve current actor using pane registry as primary source.
139
+ *
140
+ * Priority:
141
+ * 1. Look up current tmux pane in pane registry → agent name
142
+ * 2. If not in registry → 'human' (full access)
143
+ *
144
+ * Warnings:
145
+ * - If TMT_AGENT_NAME is set but conflicts with pane registry → warn about spoofing
146
+ * - If TMT_AGENT_NAME is set but pane not in registry → warn about unregistered pane
147
+ */
148
+ export function resolveActor(paneRegistry: Record<string, PaneEntry>): ActorResolution {
149
+ const envActor = process.env.TMT_AGENT_NAME || process.env.TMUX_TEAM_ACTOR;
150
+ const currentPane = getCurrentPane();
151
+
152
+ // Not in tmux - use env var or default to human
153
+ if (!currentPane) {
154
+ if (envActor) {
155
+ return { actor: envActor, source: 'env' };
156
+ }
157
+ return { actor: 'human', source: 'default' };
158
+ }
159
+
160
+ // In tmux - look up pane in registry
161
+ const paneAgent = findAgentByPane(paneRegistry, currentPane);
162
+
163
+ if (paneAgent) {
164
+ // Pane is registered to an agent
165
+ if (envActor && envActor !== paneAgent) {
166
+ // Env var conflicts with pane registry - warn about potential spoofing
167
+ return {
168
+ actor: paneAgent,
169
+ source: 'pane',
170
+ warning: `⚠️ Identity mismatch: TMT_AGENT_NAME="${envActor}" but pane ${currentPane} is registered to "${paneAgent}". Using pane identity.`,
171
+ };
172
+ }
173
+ return { actor: paneAgent, source: 'pane' };
174
+ }
175
+
176
+ // Pane not in registry
177
+ if (envActor) {
178
+ // Agent claims identity but pane not registered - warn
179
+ return {
180
+ actor: 'human',
181
+ source: 'default',
182
+ warning: `⚠️ Unregistered pane: TMT_AGENT_NAME="${envActor}" but pane ${currentPane} is not in registry. Treating as human (full access).`,
183
+ };
184
+ }
185
+
186
+ // Not registered, no env var - human
187
+ return { actor: 'human', source: 'default' };
188
+ }
189
+
190
+ /**
191
+ * Get current actor (agent name or 'human').
192
+ * Legacy function for backward compatibility.
193
+ * Reads from TMT_AGENT_NAME or TMUX_TEAM_ACTOR env vars.
194
+ */
195
+ export function getCurrentActor(): string {
196
+ return process.env.TMT_AGENT_NAME || process.env.TMUX_TEAM_ACTOR || 'human';
197
+ }
198
+
199
+ export interface PermissionResult {
200
+ allowed: boolean;
201
+ actor: string;
202
+ source: 'pane' | 'env' | 'default';
203
+ warning?: string;
204
+ }
205
+
206
+ /**
207
+ * Check if an action is allowed for the current actor.
208
+ * Uses pane-based identity resolution with warnings for conflicts.
209
+ */
210
+ export function checkPermission(config: ResolvedConfig, check: PermissionCheck): PermissionResult {
211
+ const resolution = resolveActor(config.paneRegistry);
212
+ const { actor, source, warning } = resolution;
213
+
214
+ // Human is always allowed (no deny patterns for human)
215
+ if (actor === 'human') {
216
+ return { allowed: true, actor, source, warning };
217
+ }
218
+
219
+ // Get agent config
220
+ const agentConfig = config.agents[actor];
221
+ if (!agentConfig || !agentConfig.deny || agentConfig.deny.length === 0) {
222
+ // No deny patterns = allow all
223
+ return { allowed: true, actor, source, warning };
224
+ }
225
+
226
+ // Check if any deny pattern matches
227
+ for (const pattern of agentConfig.deny) {
228
+ if (matchesPattern(check, pattern)) {
229
+ return { allowed: false, actor, source, warning };
230
+ }
231
+ }
232
+
233
+ return { allowed: true, actor, source, warning };
234
+ }
235
+
236
+ /**
237
+ * Simple permission check (legacy, for tests).
238
+ * Returns true if allowed, false if denied.
239
+ */
240
+ export function checkPermissionSimple(config: ResolvedConfig, check: PermissionCheck): boolean {
241
+ return checkPermission(config, check).allowed;
242
+ }
243
+
244
+ /**
245
+ * Build permission check for common PM operations.
246
+ */
247
+ export const PermissionChecks = {
248
+ // Task operations
249
+ taskList: (): PermissionCheck => ({ resource: 'task', action: 'list', fields: [] }),
250
+ taskShow: (): PermissionCheck => ({ resource: 'task', action: 'show', fields: [] }),
251
+ taskCreate: (): PermissionCheck => ({ resource: 'task', action: 'create', fields: [] }),
252
+ taskUpdate: (fields: string[]): PermissionCheck => ({
253
+ resource: 'task',
254
+ action: 'update',
255
+ fields,
256
+ }),
257
+ taskDelete: (): PermissionCheck => ({ resource: 'task', action: 'delete', fields: [] }),
258
+
259
+ // Milestone operations
260
+ milestoneList: (): PermissionCheck => ({ resource: 'milestone', action: 'list', fields: [] }),
261
+ milestoneCreate: (): PermissionCheck => ({ resource: 'milestone', action: 'create', fields: [] }),
262
+ milestoneUpdate: (fields: string[]): PermissionCheck => ({
263
+ resource: 'milestone',
264
+ action: 'update',
265
+ fields,
266
+ }),
267
+
268
+ // Doc operations
269
+ docRead: (): PermissionCheck => ({ resource: 'doc', action: 'read', fields: [] }),
270
+ docUpdate: (): PermissionCheck => ({ resource: 'doc', action: 'update', fields: [] }),
271
+
272
+ // Team operations
273
+ teamCreate: (): PermissionCheck => ({ resource: 'team', action: 'create', fields: [] }),
274
+ teamList: (): PermissionCheck => ({ resource: 'team', action: 'list', fields: [] }),
275
+
276
+ // Log operations
277
+ logRead: (): PermissionCheck => ({ resource: 'log', action: 'read', fields: [] }),
278
+ };
@@ -161,8 +161,9 @@ export class FSAdapter implements StorageAdapter {
161
161
  updatedAt: this.now(),
162
162
  };
163
163
  this.writeJson(path.join(this.tasksDir, `${id}.json`), task);
164
- // Create empty doc file
165
- fs.writeFileSync(path.join(this.tasksDir, `${id}.md`), `# ${input.title}\n\n`);
164
+ // Create doc file with title and optional body
165
+ const docContent = input.body ? `# ${input.title}\n\n${input.body}\n` : `# ${input.title}\n\n`;
166
+ fs.writeFileSync(path.join(this.tasksDir, `${id}.md`), docContent);
166
167
  return task;
167
168
  }
168
169
 
@@ -61,13 +61,13 @@ interface GHMilestone {
61
61
  // Local cache for ID mapping (task ID -> issue number)
62
62
  // ─────────────────────────────────────────────────────────────
63
63
 
64
- const CACHE_VERSION = 1;
64
+ const CACHE_VERSION = 2;
65
65
 
66
66
  interface IdCache {
67
67
  version: number; // Cache format version for migrations
68
68
  repo: string; // Associated repo to detect cross-repo drift
69
69
  tasks: Record<string, number>; // task ID -> issue number
70
- milestones: Record<string, number>; // milestone ID -> milestone number
70
+ milestones: Record<string, { number: number; name: string }>; // milestone ID -> {number, name}
71
71
  nextTaskId: number;
72
72
  nextMilestoneId: number;
73
73
  }
@@ -89,9 +89,12 @@ export class GitHubAdapter implements StorageAdapter {
89
89
  // Helper: Execute gh CLI command safely (no shell injection)
90
90
  // ─────────────────────────────────────────────────────────────
91
91
 
92
- private gh(args: string[]): string {
92
+ private gh(args: string[], options?: { skipRepo?: boolean }): string {
93
93
  const fullArgs = [...args];
94
- if (this.repo) {
94
+ // gh api doesn't accept --repo flag (repo is in the endpoint path)
95
+ // Other commands like 'gh issue' do accept --repo
96
+ const isApiCommand = args[0] === 'api';
97
+ if (this.repo && !isApiCommand && !options?.skipRepo) {
95
98
  fullArgs.push('--repo', this.repo);
96
99
  }
97
100
  // Use spawnSync with array args to avoid shell injection
@@ -195,7 +198,12 @@ export class GitHubAdapter implements StorageAdapter {
195
198
 
196
199
  private getMilestoneNumber(milestoneId: string): number | null {
197
200
  const cache = this.loadCache();
198
- return cache.milestones[milestoneId] ?? null;
201
+ return cache.milestones[milestoneId]?.number ?? null;
202
+ }
203
+
204
+ private getMilestoneName(milestoneId: string): string | null {
205
+ const cache = this.loadCache();
206
+ return cache.milestones[milestoneId]?.name ?? null;
199
207
  }
200
208
 
201
209
  // ─────────────────────────────────────────────────────────────
@@ -226,7 +234,7 @@ export class GitHubAdapter implements StorageAdapter {
226
234
  if (issue.milestone?.number) {
227
235
  const c = cache || this.loadCache();
228
236
  const ghMilestoneNum = issue.milestone.number;
229
- milestoneId = Object.entries(c.milestones).find(([, num]) => num === ghMilestoneNum)?.[0];
237
+ milestoneId = Object.entries(c.milestones).find(([, m]) => m.number === ghMilestoneNum)?.[0];
230
238
  }
231
239
 
232
240
  return {
@@ -352,10 +360,10 @@ export class GitHubAdapter implements StorageAdapter {
352
360
  ]);
353
361
  const ghMilestone = JSON.parse(result) as { number: number; title: string; created_at: string };
354
362
 
355
- // Cache the ID mapping
363
+ // Cache the ID mapping (store both number and name)
356
364
  const cache = this.loadCache();
357
365
  const id = String(cache.nextMilestoneId++);
358
- cache.milestones[id] = ghMilestone.number;
366
+ cache.milestones[id] = { number: ghMilestone.number, name: ghMilestone.title };
359
367
  this.saveCache(cache);
360
368
 
361
369
  return {
@@ -404,11 +412,11 @@ export class GitHubAdapter implements StorageAdapter {
404
412
 
405
413
  // Match with cached IDs, or create new mappings
406
414
  for (const ghm of ghMilestones) {
407
- let id = Object.entries(cache.milestones).find(([, num]) => num === ghm.number)?.[0];
415
+ let id = Object.entries(cache.milestones).find(([, m]) => m.number === ghm.number)?.[0];
408
416
  if (!id) {
409
417
  // New milestone from GitHub, assign ID
410
418
  id = String(cache.nextMilestoneId++);
411
- cache.milestones[id] = ghm.number;
419
+ cache.milestones[id] = { number: ghm.number, name: ghm.title };
412
420
  }
413
421
  milestones.push(this.milestoneToMilestone(ghm, id));
414
422
  }
@@ -468,24 +476,26 @@ export class GitHubAdapter implements StorageAdapter {
468
476
  'create',
469
477
  '--title',
470
478
  input.title,
479
+ '--body',
480
+ input.body || '', // Required for non-interactive mode
471
481
  '--label',
472
482
  LABELS.TASK,
473
483
  '--label',
474
484
  LABELS.PENDING,
475
485
  ];
476
486
 
477
- // Add milestone if specified
487
+ // Add milestone if specified (gh issue create expects milestone name, not number)
478
488
  if (input.milestone) {
479
- const milestoneNum = this.getMilestoneNumber(input.milestone);
480
- if (milestoneNum) {
481
- args.push('--milestone', String(milestoneNum));
489
+ const milestoneName = this.getMilestoneName(input.milestone);
490
+ if (milestoneName) {
491
+ args.push('--milestone', milestoneName);
482
492
  }
483
493
  }
484
494
 
485
- // Add assignee if specified
486
- if (input.assignee) {
487
- args.push('--assignee', input.assignee);
488
- }
495
+ // NOTE: Assignee is intentionally NOT supported for GitHub backend.
496
+ // Agent names (e.g., "codex") don't map to GitHub usernames, and passing
497
+ // them could accidentally notify unrelated GitHub users or fail silently.
498
+ // Use labels or comments for agent attribution instead.
489
499
 
490
500
  // Create issue and get its number
491
501
  const url = this.gh(args);
@@ -528,7 +538,14 @@ export class GitHubAdapter implements StorageAdapter {
528
538
  }
529
539
 
530
540
  async listTasks(filter?: ListTasksFilter): Promise<Task[]> {
531
- const cache = this.loadCache();
541
+ let cache = this.loadCache();
542
+
543
+ // Rebuild milestone cache if empty (e.g., after cache reset)
544
+ if (Object.keys(cache.milestones).length === 0) {
545
+ await this.listMilestones(); // This populates the cache
546
+ cache = this.loadCache(); // Reload updated cache
547
+ }
548
+
532
549
  const args = [
533
550
  'issue',
534
551
  'list',
@@ -548,18 +565,15 @@ export class GitHubAdapter implements StorageAdapter {
548
565
  args.push('--state', 'all');
549
566
  }
550
567
 
551
- // Filter by milestone
568
+ // Filter by milestone (gh issue list expects milestone name)
552
569
  if (filter?.milestone) {
553
- const milestoneNum = this.getMilestoneNumber(filter.milestone);
554
- if (milestoneNum) {
555
- args.push('--milestone', String(milestoneNum));
570
+ const milestoneName = this.getMilestoneName(filter.milestone);
571
+ if (milestoneName) {
572
+ args.push('--milestone', milestoneName);
556
573
  }
557
574
  }
558
575
 
559
- // Filter by assignee
560
- if (filter?.assignee) {
561
- args.push('--assignee', filter.assignee);
562
- }
576
+ // NOTE: Assignee filter not supported for GitHub backend (security risk)
563
577
 
564
578
  // Only get tmux-team managed issues (all have TASK label)
565
579
  args.push('--label', LABELS.TASK);
@@ -601,14 +615,12 @@ export class GitHubAdapter implements StorageAdapter {
601
615
  args.push('--title', input.title);
602
616
  }
603
617
 
604
- if (input.assignee) {
605
- args.push('--add-assignee', input.assignee);
606
- }
618
+ // NOTE: Assignee updates are not supported for GitHub backend (security risk)
607
619
 
608
620
  if (input.milestone) {
609
- const milestoneNum = this.getMilestoneNumber(input.milestone);
610
- if (milestoneNum) {
611
- args.push('--milestone', String(milestoneNum));
621
+ const milestoneName = this.getMilestoneName(input.milestone);
622
+ if (milestoneName) {
623
+ args.push('--milestone', milestoneName);
612
624
  }
613
625
  }
614
626
 
package/src/pm/types.ts CHANGED
@@ -41,6 +41,7 @@ export interface AuditEvent {
41
41
 
42
42
  export interface CreateTaskInput {
43
43
  title: string;
44
+ body?: string;
44
45
  milestone?: string;
45
46
  assignee?: string;
46
47
  }
@@ -77,3 +78,8 @@ export interface TeamConfig {
77
78
  backend: StorageBackend;
78
79
  repo?: string; // GitHub repo (owner/repo format) for github backend
79
80
  }
81
+
82
+ export interface TeamWithConfig extends Team {
83
+ backend: StorageBackend;
84
+ repo?: string;
85
+ }
package/src/types.ts CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  export interface AgentConfig {
6
6
  preamble?: string;
7
+ deny?: string[]; // Permission deny patterns, e.g., ["pm:task:update(status)"]
7
8
  }
8
9
 
9
10
  export interface PaneEntry {
@@ -24,6 +25,16 @@ export interface GlobalConfig {
24
25
  agents: Record<string, AgentConfig>;
25
26
  }
26
27
 
28
+ export interface LocalSettings {
29
+ mode?: 'polling' | 'wait';
30
+ preambleMode?: 'always' | 'disabled';
31
+ }
32
+
33
+ export interface LocalConfigFile {
34
+ $config?: LocalSettings;
35
+ [agentName: string]: PaneEntry | LocalSettings | undefined;
36
+ }
37
+
27
38
  export interface LocalConfig {
28
39
  [agentName: string]: PaneEntry;
29
40
  }
package/src/ui.ts CHANGED
@@ -6,6 +6,9 @@ import type { UI } from './types.js';
6
6
 
7
7
  const isTTY = process.stdout.isTTY;
8
8
 
9
+ // Strip ANSI escape codes for accurate length calculation
10
+ const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, '');
11
+
9
12
  export const colors = {
10
13
  red: (s: string) => (isTTY ? `\x1b[31m${s}\x1b[0m` : s),
11
14
  green: (s: string) => (isTTY ? `\x1b[32m${s}\x1b[0m` : s),
@@ -46,18 +49,24 @@ export function createUI(jsonMode: boolean): UI {
46
49
  console.error(`${colors.red('✗')} ${msg}`);
47
50
  },
48
51
  table: (headers: string[], rows: string[][]) => {
49
- // Calculate column widths
52
+ // Calculate column widths (strip ANSI codes for accurate length)
50
53
  const widths = headers.map((h, i) =>
51
- Math.max(h.length, ...rows.map((r) => (r[i] || '').length))
54
+ Math.max(h.length, ...rows.map((r) => stripAnsi(r[i] || '').length))
52
55
  );
53
56
 
54
57
  // Print header
55
58
  console.log(' ' + headers.map((h, i) => colors.yellow(h.padEnd(widths[i]))).join(' '));
56
59
  console.log(' ' + widths.map((w) => '─'.repeat(w)).join(' '));
57
60
 
58
- // Print rows
61
+ // Print rows (pad based on visible length, not byte length)
59
62
  for (const row of rows) {
60
- console.log(' ' + row.map((c, i) => (c || '-').padEnd(widths[i])).join(' '));
63
+ const cells = row.map((c, i) => {
64
+ const cell = c || '-';
65
+ const visibleLen = stripAnsi(cell).length;
66
+ const padding = ' '.repeat(Math.max(0, widths[i] - visibleLen));
67
+ return cell + padding;
68
+ });
69
+ console.log(' ' + cells.join(' '));
61
70
  }
62
71
  },
63
72
  json: (data: unknown) => {