tmux-team 2.0.0-alpha.1 → 2.0.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.
@@ -5,6 +5,12 @@
5
5
  import type { Context } from '../types.js';
6
6
  import { ExitCodes } from '../exits.js';
7
7
  import { colors } from '../ui.js';
8
+ import {
9
+ checkPermission,
10
+ buildPermissionPath,
11
+ PermissionChecks,
12
+ type PermissionCheck,
13
+ } from './permissions.js';
8
14
  import {
9
15
  findCurrentTeamId,
10
16
  getStorageAdapter,
@@ -62,11 +68,28 @@ function parseStatus(s: string): TaskStatus {
62
68
  throw new Error(`Invalid status: ${s}. Use: pending, in_progress, done`);
63
69
  }
64
70
 
71
+ function requirePermission(ctx: Context, check: PermissionCheck): void {
72
+ const result = checkPermission(ctx.config, check);
73
+
74
+ // Display warning if there's an identity conflict
75
+ if (result.warning) {
76
+ ctx.ui.warn(result.warning);
77
+ }
78
+
79
+ if (!result.allowed) {
80
+ const permPath = buildPermissionPath(check);
81
+ ctx.ui.error(`Permission denied: ${result.actor} cannot perform ${permPath}`);
82
+ ctx.exit(ExitCodes.ERROR);
83
+ }
84
+ }
85
+
65
86
  // ─────────────────────────────────────────────────────────────
66
87
  // Commands
67
88
  // ─────────────────────────────────────────────────────────────
68
89
 
69
90
  export async function cmdPmInit(ctx: Context, args: string[]): Promise<void> {
91
+ requirePermission(ctx, PermissionChecks.teamCreate());
92
+
70
93
  const { ui, flags, paths } = ctx;
71
94
 
72
95
  // Parse flags: --name, --backend, --repo
@@ -157,6 +180,8 @@ export async function cmdPmMilestone(ctx: Context, args: string[]): Promise<void
157
180
  }
158
181
 
159
182
  async function cmdMilestoneAdd(ctx: Context, args: string[]): Promise<void> {
183
+ requirePermission(ctx, PermissionChecks.milestoneCreate());
184
+
160
185
  const { ui, flags } = ctx;
161
186
  const { storage } = await requireTeam(ctx);
162
187
 
@@ -184,6 +209,8 @@ async function cmdMilestoneAdd(ctx: Context, args: string[]): Promise<void> {
184
209
  }
185
210
 
186
211
  async function cmdMilestoneList(ctx: Context, _args: string[]): Promise<void> {
212
+ requirePermission(ctx, PermissionChecks.milestoneList());
213
+
187
214
  const { ui, flags } = ctx;
188
215
  const { storage } = await requireTeam(ctx);
189
216
 
@@ -208,6 +235,8 @@ async function cmdMilestoneList(ctx: Context, _args: string[]): Promise<void> {
208
235
  }
209
236
 
210
237
  async function cmdMilestoneDone(ctx: Context, args: string[]): Promise<void> {
238
+ requirePermission(ctx, PermissionChecks.milestoneUpdate(['status']));
239
+
211
240
  const { ui, flags } = ctx;
212
241
  const { storage } = await requireTeam(ctx);
213
242
 
@@ -264,11 +293,14 @@ export async function cmdPmTask(ctx: Context, args: string[]): Promise<void> {
264
293
  }
265
294
 
266
295
  async function cmdTaskAdd(ctx: Context, args: string[]): Promise<void> {
296
+ requirePermission(ctx, PermissionChecks.taskCreate());
297
+
267
298
  const { ui, flags } = ctx;
268
299
  const { storage } = await requireTeam(ctx);
269
300
 
270
- // Parse args: <title> [--milestone <id>] [--assignee <name>]
301
+ // Parse args: <title> [--milestone <id>] [--assignee <name>] [--body <text>]
271
302
  let title = '';
303
+ let body: string | undefined;
272
304
  let milestone: string | undefined;
273
305
  let assignee: string | undefined;
274
306
 
@@ -281,6 +313,10 @@ async function cmdTaskAdd(ctx: Context, args: string[]): Promise<void> {
281
313
  assignee = args[++i];
282
314
  } else if (args[i].startsWith('--assignee=')) {
283
315
  assignee = args[i].slice(11);
316
+ } else if (args[i] === '--body' || args[i] === '-b') {
317
+ body = args[++i];
318
+ } else if (args[i].startsWith('--body=')) {
319
+ body = args[i].slice(7);
284
320
  } else if (!title) {
285
321
  title = args[i];
286
322
  }
@@ -291,7 +327,7 @@ async function cmdTaskAdd(ctx: Context, args: string[]): Promise<void> {
291
327
  ctx.exit(ExitCodes.ERROR);
292
328
  }
293
329
 
294
- const task = await storage.createTask({ title, milestone, assignee });
330
+ const task = await storage.createTask({ title, body, milestone, assignee });
295
331
 
296
332
  await storage.appendEvent({
297
333
  event: 'task_created',
@@ -310,6 +346,8 @@ async function cmdTaskAdd(ctx: Context, args: string[]): Promise<void> {
310
346
  }
311
347
 
312
348
  async function cmdTaskList(ctx: Context, args: string[]): Promise<void> {
349
+ requirePermission(ctx, PermissionChecks.taskList());
350
+
313
351
  const { ui, flags } = ctx;
314
352
  const { storage } = await requireTeam(ctx);
315
353
 
@@ -350,6 +388,8 @@ async function cmdTaskList(ctx: Context, args: string[]): Promise<void> {
350
388
  }
351
389
 
352
390
  async function cmdTaskShow(ctx: Context, args: string[]): Promise<void> {
391
+ requirePermission(ctx, PermissionChecks.taskShow());
392
+
353
393
  const { ui, flags } = ctx;
354
394
  const { storage } = await requireTeam(ctx);
355
395
 
@@ -382,7 +422,6 @@ async function cmdTaskShow(ctx: Context, args: string[]): Promise<void> {
382
422
 
383
423
  async function cmdTaskUpdate(ctx: Context, args: string[]): Promise<void> {
384
424
  const { ui, flags } = ctx;
385
- const { storage } = await requireTeam(ctx);
386
425
 
387
426
  // Parse: <id> --status <status> [--assignee <name>]
388
427
  const id = args[0];
@@ -406,6 +445,15 @@ async function cmdTaskUpdate(ctx: Context, args: string[]): Promise<void> {
406
445
  }
407
446
  }
408
447
 
448
+ // Check permissions based on which fields are being updated
449
+ const fields: string[] = [];
450
+ if (status) fields.push('status');
451
+ if (assignee) fields.push('assignee');
452
+ if (fields.length > 0) {
453
+ requirePermission(ctx, PermissionChecks.taskUpdate(fields));
454
+ }
455
+
456
+ const { storage } = await requireTeam(ctx);
409
457
  const task = await storage.getTask(id);
410
458
  if (!task) {
411
459
  ui.error(`Task ${id} not found`);
@@ -443,6 +491,8 @@ async function cmdTaskUpdate(ctx: Context, args: string[]): Promise<void> {
443
491
  }
444
492
 
445
493
  async function cmdTaskDone(ctx: Context, args: string[]): Promise<void> {
494
+ requirePermission(ctx, PermissionChecks.taskUpdate(['status']));
495
+
446
496
  const { ui, flags } = ctx;
447
497
  const { storage } = await requireTeam(ctx);
448
498
 
@@ -479,7 +529,6 @@ async function cmdTaskDone(ctx: Context, args: string[]): Promise<void> {
479
529
 
480
530
  export async function cmdPmDoc(ctx: Context, args: string[]): Promise<void> {
481
531
  const { ui, flags } = ctx;
482
- const { teamId, storage } = await requireTeam(ctx);
483
532
 
484
533
  const id = args[0];
485
534
  if (!id) {
@@ -487,13 +536,22 @@ export async function cmdPmDoc(ctx: Context, args: string[]): Promise<void> {
487
536
  ctx.exit(ExitCodes.ERROR);
488
537
  }
489
538
 
539
+ const printOnly = args.includes('--print') || args.includes('-p');
540
+
541
+ // Check permission based on mode
542
+ if (printOnly || flags.json) {
543
+ requirePermission(ctx, PermissionChecks.docRead());
544
+ } else {
545
+ requirePermission(ctx, PermissionChecks.docUpdate());
546
+ }
547
+
548
+ const { teamId, storage } = await requireTeam(ctx);
490
549
  const task = await storage.getTask(id);
491
550
  if (!task) {
492
551
  ui.error(`Task ${id} not found`);
493
552
  ctx.exit(ExitCodes.PANE_NOT_FOUND);
494
553
  }
495
554
 
496
- const printOnly = args.includes('--print') || args.includes('-p');
497
555
  const doc = await storage.getTaskDoc(id);
498
556
 
499
557
  if (printOnly || flags.json) {
@@ -516,6 +574,8 @@ export async function cmdPmDoc(ctx: Context, args: string[]): Promise<void> {
516
574
  }
517
575
 
518
576
  export async function cmdPmLog(ctx: Context, args: string[]): Promise<void> {
577
+ requirePermission(ctx, PermissionChecks.logRead());
578
+
519
579
  const { ui, flags } = ctx;
520
580
  const { storage } = await requireTeam(ctx);
521
581
 
@@ -553,12 +613,15 @@ export async function cmdPmLog(ctx: Context, args: string[]): Promise<void> {
553
613
  }
554
614
 
555
615
  export async function cmdPmList(ctx: Context, _args: string[]): Promise<void> {
616
+ requirePermission(ctx, PermissionChecks.teamList());
617
+
556
618
  const { ui, flags, paths } = ctx;
557
619
 
558
620
  const teams = listTeams(paths.globalDir);
621
+ const currentTeamId = findCurrentTeamId(process.cwd(), paths.globalDir);
559
622
 
560
623
  if (flags.json) {
561
- ui.json(teams);
624
+ ui.json({ teams, currentTeamId });
562
625
  return;
563
626
  }
564
627
 
@@ -569,8 +632,14 @@ export async function cmdPmList(ctx: Context, _args: string[]): Promise<void> {
569
632
 
570
633
  console.log();
571
634
  ui.table(
572
- ['ID', 'NAME', 'CREATED'],
573
- teams.map((t) => [t.id.slice(0, 8) + '...', t.name, t.createdAt.slice(0, 10)])
635
+ ['', 'ID', 'NAME', 'BACKEND', 'CREATED'],
636
+ teams.map((t) => [
637
+ t.id === currentTeamId ? colors.green('→') : ' ',
638
+ t.id.slice(0, 8) + '...',
639
+ t.name,
640
+ t.backend === 'github' ? colors.cyan('github') : colors.dim('fs'),
641
+ t.createdAt.slice(0, 10),
642
+ ])
574
643
  );
575
644
  console.log();
576
645
  }
package/src/pm/manager.ts CHANGED
@@ -8,7 +8,7 @@ import crypto from 'crypto';
8
8
  import type { StorageAdapter } from './storage/adapter.js';
9
9
  import { createFSAdapter } from './storage/fs.js';
10
10
  import { createGitHubAdapter } from './storage/github.js';
11
- import type { Team, TeamConfig, StorageBackend } from './types.js';
11
+ import type { Team, TeamConfig, TeamWithConfig, StorageBackend } from './types.js';
12
12
 
13
13
  /**
14
14
  * Resolve the teams directory from global config path.
@@ -107,21 +107,27 @@ export function generateTeamId(): string {
107
107
  }
108
108
 
109
109
  /**
110
- * List all teams.
110
+ * List all teams with their backend config.
111
111
  */
112
- export function listTeams(globalDir: string): Team[] {
112
+ export function listTeams(globalDir: string): TeamWithConfig[] {
113
113
  const teamsDir = getTeamsDir(globalDir);
114
114
  if (!fs.existsSync(teamsDir)) return [];
115
115
 
116
- const teams: Team[] = [];
116
+ const teams: TeamWithConfig[] = [];
117
117
  const dirs = fs.readdirSync(teamsDir);
118
118
 
119
119
  for (const dir of dirs) {
120
- const teamFile = path.join(teamsDir, dir, 'team.json');
120
+ const teamDir = path.join(teamsDir, dir);
121
+ const teamFile = path.join(teamDir, 'team.json');
121
122
  if (fs.existsSync(teamFile)) {
122
123
  try {
123
124
  const team = JSON.parse(fs.readFileSync(teamFile, 'utf-8')) as Team;
124
- teams.push(team);
125
+ const config = getTeamConfig(teamDir);
126
+ teams.push({
127
+ ...team,
128
+ backend: config?.backend || 'fs',
129
+ repo: config?.repo,
130
+ });
125
131
  } catch {
126
132
  // Skip malformed team files
127
133
  }
@@ -0,0 +1,332 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // Permission System Tests
3
+ // ─────────────────────────────────────────────────────────────
4
+
5
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
6
+ import {
7
+ buildPermissionPath,
8
+ checkPermission,
9
+ getCurrentActor,
10
+ resolveActor,
11
+ PermissionChecks,
12
+ } from './permissions.js';
13
+ import type { ResolvedConfig } from '../types.js';
14
+
15
+ function createMockConfig(agents: Record<string, { deny?: string[] }>): ResolvedConfig {
16
+ return {
17
+ mode: 'polling',
18
+ preambleMode: 'always',
19
+ defaults: { timeout: 60, pollInterval: 1, captureLines: 100 },
20
+ agents,
21
+ paneRegistry: {},
22
+ };
23
+ }
24
+
25
+ describe('buildPermissionPath', () => {
26
+ it('builds path without fields', () => {
27
+ expect(buildPermissionPath({ resource: 'task', action: 'list', fields: [] })).toBe(
28
+ 'pm:task:list'
29
+ );
30
+ });
31
+
32
+ it('builds path with single field', () => {
33
+ expect(buildPermissionPath({ resource: 'task', action: 'update', fields: ['status'] })).toBe(
34
+ 'pm:task:update(status)'
35
+ );
36
+ });
37
+
38
+ it('builds path with multiple fields sorted alphabetically', () => {
39
+ expect(
40
+ buildPermissionPath({ resource: 'task', action: 'update', fields: ['status', 'assignee'] })
41
+ ).toBe('pm:task:update(assignee,status)');
42
+ });
43
+
44
+ it('builds path with fields already sorted', () => {
45
+ expect(
46
+ buildPermissionPath({ resource: 'task', action: 'update', fields: ['assignee', 'status'] })
47
+ ).toBe('pm:task:update(assignee,status)');
48
+ });
49
+ });
50
+
51
+ describe('getCurrentActor', () => {
52
+ const originalEnv = { ...process.env };
53
+
54
+ afterEach(() => {
55
+ process.env = { ...originalEnv };
56
+ });
57
+
58
+ it('returns TMT_AGENT_NAME if set', () => {
59
+ process.env.TMT_AGENT_NAME = 'codex';
60
+ delete process.env.TMUX_TEAM_ACTOR;
61
+ expect(getCurrentActor()).toBe('codex');
62
+ });
63
+
64
+ it('returns TMUX_TEAM_ACTOR if TMT_AGENT_NAME not set', () => {
65
+ delete process.env.TMT_AGENT_NAME;
66
+ process.env.TMUX_TEAM_ACTOR = 'gemini';
67
+ expect(getCurrentActor()).toBe('gemini');
68
+ });
69
+
70
+ it('returns human if no env vars set', () => {
71
+ delete process.env.TMT_AGENT_NAME;
72
+ delete process.env.TMUX_TEAM_ACTOR;
73
+ expect(getCurrentActor()).toBe('human');
74
+ });
75
+
76
+ it('prefers TMT_AGENT_NAME over TMUX_TEAM_ACTOR', () => {
77
+ process.env.TMT_AGENT_NAME = 'codex';
78
+ process.env.TMUX_TEAM_ACTOR = 'gemini';
79
+ expect(getCurrentActor()).toBe('codex');
80
+ });
81
+ });
82
+
83
+ describe('checkPermission', () => {
84
+ const originalEnv = { ...process.env };
85
+
86
+ beforeEach(() => {
87
+ process.env = { ...originalEnv };
88
+ // Disable pane detection in tests by unsetting TMUX
89
+ delete process.env.TMUX;
90
+ });
91
+
92
+ afterEach(() => {
93
+ process.env = { ...originalEnv };
94
+ });
95
+
96
+ it('allows everything for human actor', () => {
97
+ delete process.env.TMT_AGENT_NAME;
98
+ delete process.env.TMUX_TEAM_ACTOR;
99
+
100
+ const config = createMockConfig({
101
+ codex: { deny: ['pm:task:update(status)'] },
102
+ });
103
+
104
+ expect(checkPermission(config, PermissionChecks.taskUpdate(['status'])).allowed).toBe(true);
105
+ });
106
+
107
+ it('allows when no deny patterns for agent', () => {
108
+ process.env.TMT_AGENT_NAME = 'codex';
109
+
110
+ const config = createMockConfig({
111
+ codex: {}, // No deny patterns
112
+ });
113
+
114
+ expect(checkPermission(config, PermissionChecks.taskUpdate(['status'])).allowed).toBe(true);
115
+ });
116
+
117
+ it('allows when agent not in config', () => {
118
+ process.env.TMT_AGENT_NAME = 'unknown-agent';
119
+
120
+ const config = createMockConfig({
121
+ codex: { deny: ['pm:task:update(status)'] },
122
+ });
123
+
124
+ expect(checkPermission(config, PermissionChecks.taskUpdate(['status'])).allowed).toBe(true);
125
+ });
126
+
127
+ it('denies when pattern matches exactly', () => {
128
+ process.env.TMT_AGENT_NAME = 'codex';
129
+
130
+ const config = createMockConfig({
131
+ codex: { deny: ['pm:task:update(status)'] },
132
+ });
133
+
134
+ expect(checkPermission(config, PermissionChecks.taskUpdate(['status'])).allowed).toBe(false);
135
+ });
136
+
137
+ it('denies when pattern matches any field', () => {
138
+ process.env.TMT_AGENT_NAME = 'codex';
139
+
140
+ const config = createMockConfig({
141
+ codex: { deny: ['pm:task:update(status)'] },
142
+ });
143
+
144
+ // Using both status and assignee, should still be denied because status is in deny list
145
+ expect(
146
+ checkPermission(config, PermissionChecks.taskUpdate(['status', 'assignee'])).allowed
147
+ ).toBe(false);
148
+ });
149
+
150
+ it('allows when fields do not match', () => {
151
+ process.env.TMT_AGENT_NAME = 'codex';
152
+
153
+ const config = createMockConfig({
154
+ codex: { deny: ['pm:task:update(status)'] },
155
+ });
156
+
157
+ // Only updating assignee, not status
158
+ expect(checkPermission(config, PermissionChecks.taskUpdate(['assignee'])).allowed).toBe(true);
159
+ });
160
+
161
+ it('denies entire action when pattern has no fields', () => {
162
+ process.env.TMT_AGENT_NAME = 'codex';
163
+
164
+ const config = createMockConfig({
165
+ codex: { deny: ['pm:task:update'] },
166
+ });
167
+
168
+ // Any update should be denied
169
+ expect(checkPermission(config, PermissionChecks.taskUpdate(['status'])).allowed).toBe(false);
170
+ expect(checkPermission(config, PermissionChecks.taskUpdate(['assignee'])).allowed).toBe(false);
171
+ expect(checkPermission(config, PermissionChecks.taskUpdate([])).allowed).toBe(false);
172
+ });
173
+
174
+ it('denies when wildcard pattern matches any field', () => {
175
+ process.env.TMT_AGENT_NAME = 'codex';
176
+
177
+ const config = createMockConfig({
178
+ codex: { deny: ['pm:task:update(*)'] },
179
+ });
180
+
181
+ expect(checkPermission(config, PermissionChecks.taskUpdate(['status'])).allowed).toBe(false);
182
+ expect(checkPermission(config, PermissionChecks.taskUpdate(['assignee'])).allowed).toBe(false);
183
+ });
184
+
185
+ it('allows no-field action when wildcard is used', () => {
186
+ process.env.TMT_AGENT_NAME = 'codex';
187
+
188
+ const config = createMockConfig({
189
+ codex: { deny: ['pm:task:update(*)'] },
190
+ });
191
+
192
+ // Wildcard only matches when fields are present
193
+ expect(checkPermission(config, PermissionChecks.taskUpdate([])).allowed).toBe(true);
194
+ });
195
+
196
+ it('allows different resource', () => {
197
+ process.env.TMT_AGENT_NAME = 'codex';
198
+
199
+ const config = createMockConfig({
200
+ codex: { deny: ['pm:task:update(status)'] },
201
+ });
202
+
203
+ expect(checkPermission(config, PermissionChecks.milestoneUpdate(['status'])).allowed).toBe(
204
+ true
205
+ );
206
+ });
207
+
208
+ it('allows different action', () => {
209
+ process.env.TMT_AGENT_NAME = 'codex';
210
+
211
+ const config = createMockConfig({
212
+ codex: { deny: ['pm:task:update(status)'] },
213
+ });
214
+
215
+ expect(checkPermission(config, PermissionChecks.taskCreate()).allowed).toBe(true);
216
+ expect(checkPermission(config, PermissionChecks.taskList()).allowed).toBe(true);
217
+ });
218
+
219
+ it('handles multiple deny patterns', () => {
220
+ process.env.TMT_AGENT_NAME = 'codex';
221
+
222
+ const config = createMockConfig({
223
+ codex: {
224
+ deny: ['pm:task:update(status)', 'pm:milestone:update(status)', 'pm:task:delete'],
225
+ },
226
+ });
227
+
228
+ expect(checkPermission(config, PermissionChecks.taskUpdate(['status'])).allowed).toBe(false);
229
+ expect(checkPermission(config, PermissionChecks.milestoneUpdate(['status'])).allowed).toBe(
230
+ false
231
+ );
232
+ expect(checkPermission(config, PermissionChecks.taskDelete()).allowed).toBe(false);
233
+ expect(checkPermission(config, PermissionChecks.taskUpdate(['assignee'])).allowed).toBe(true);
234
+ });
235
+ });
236
+
237
+ describe('PermissionChecks helpers', () => {
238
+ it('creates correct task checks', () => {
239
+ expect(PermissionChecks.taskList()).toEqual({ resource: 'task', action: 'list', fields: [] });
240
+ expect(PermissionChecks.taskShow()).toEqual({ resource: 'task', action: 'show', fields: [] });
241
+ expect(PermissionChecks.taskCreate()).toEqual({
242
+ resource: 'task',
243
+ action: 'create',
244
+ fields: [],
245
+ });
246
+ expect(PermissionChecks.taskUpdate(['status'])).toEqual({
247
+ resource: 'task',
248
+ action: 'update',
249
+ fields: ['status'],
250
+ });
251
+ expect(PermissionChecks.taskDelete()).toEqual({
252
+ resource: 'task',
253
+ action: 'delete',
254
+ fields: [],
255
+ });
256
+ });
257
+
258
+ it('creates correct milestone checks', () => {
259
+ expect(PermissionChecks.milestoneList()).toEqual({
260
+ resource: 'milestone',
261
+ action: 'list',
262
+ fields: [],
263
+ });
264
+ expect(PermissionChecks.milestoneCreate()).toEqual({
265
+ resource: 'milestone',
266
+ action: 'create',
267
+ fields: [],
268
+ });
269
+ expect(PermissionChecks.milestoneUpdate(['status'])).toEqual({
270
+ resource: 'milestone',
271
+ action: 'update',
272
+ fields: ['status'],
273
+ });
274
+ });
275
+
276
+ it('creates correct doc checks', () => {
277
+ expect(PermissionChecks.docRead()).toEqual({ resource: 'doc', action: 'read', fields: [] });
278
+ expect(PermissionChecks.docUpdate()).toEqual({ resource: 'doc', action: 'update', fields: [] });
279
+ });
280
+
281
+ it('creates correct team checks', () => {
282
+ expect(PermissionChecks.teamCreate()).toEqual({
283
+ resource: 'team',
284
+ action: 'create',
285
+ fields: [],
286
+ });
287
+ expect(PermissionChecks.teamList()).toEqual({ resource: 'team', action: 'list', fields: [] });
288
+ });
289
+
290
+ it('creates correct log checks', () => {
291
+ expect(PermissionChecks.logRead()).toEqual({ resource: 'log', action: 'read', fields: [] });
292
+ });
293
+ });
294
+
295
+ describe('resolveActor', () => {
296
+ const originalEnv = { ...process.env };
297
+
298
+ afterEach(() => {
299
+ process.env = { ...originalEnv };
300
+ });
301
+
302
+ it('returns human when not in tmux and no env var', () => {
303
+ delete process.env.TMUX;
304
+ delete process.env.TMT_AGENT_NAME;
305
+ delete process.env.TMUX_TEAM_ACTOR;
306
+
307
+ const result = resolveActor({});
308
+ expect(result.actor).toBe('human');
309
+ expect(result.source).toBe('default');
310
+ expect(result.warning).toBeUndefined();
311
+ });
312
+
313
+ it('uses env var when not in tmux', () => {
314
+ delete process.env.TMUX;
315
+ process.env.TMT_AGENT_NAME = 'codex';
316
+
317
+ const result = resolveActor({});
318
+ expect(result.actor).toBe('codex');
319
+ expect(result.source).toBe('env');
320
+ expect(result.warning).toBeUndefined();
321
+ });
322
+
323
+ it('prefers TMT_AGENT_NAME over TMUX_TEAM_ACTOR', () => {
324
+ delete process.env.TMUX;
325
+ process.env.TMT_AGENT_NAME = 'codex';
326
+ process.env.TMUX_TEAM_ACTOR = 'gemini';
327
+
328
+ const result = resolveActor({});
329
+ expect(result.actor).toBe('codex');
330
+ expect(result.source).toBe('env');
331
+ });
332
+ });