atris 2.5.5 → 2.6.1

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 (44) hide show
  1. package/GETTING_STARTED.md +2 -2
  2. package/atris/GETTING_STARTED.md +2 -2
  3. package/atris/policies/atris-design.md +200 -15
  4. package/atris/skills/design/SKILL.md +32 -7
  5. package/bin/atris.js +34 -10
  6. package/commands/auth.js +317 -67
  7. package/commands/context-sync.js +226 -0
  8. package/commands/pull.js +118 -40
  9. package/commands/push.js +150 -61
  10. package/lib/manifest.js +222 -0
  11. package/package.json +9 -4
  12. package/utils/auth.js +127 -0
  13. package/AGENT.md +0 -35
  14. package/atris/experiments/README.md +0 -118
  15. package/atris/experiments/_examples/smoke-keep-revert/README.md +0 -45
  16. package/atris/experiments/_examples/smoke-keep-revert/candidate.py +0 -8
  17. package/atris/experiments/_examples/smoke-keep-revert/loop.py +0 -129
  18. package/atris/experiments/_examples/smoke-keep-revert/measure.py +0 -47
  19. package/atris/experiments/_examples/smoke-keep-revert/program.md +0 -3
  20. package/atris/experiments/_examples/smoke-keep-revert/proposals/bad_patch.py +0 -19
  21. package/atris/experiments/_examples/smoke-keep-revert/proposals/fix_patch.py +0 -22
  22. package/atris/experiments/_examples/smoke-keep-revert/reset.py +0 -21
  23. package/atris/experiments/_examples/smoke-keep-revert/results.tsv +0 -5
  24. package/atris/experiments/_examples/smoke-keep-revert/visual.svg +0 -52
  25. package/atris/experiments/_fixtures/invalid/BadName/loop.py +0 -1
  26. package/atris/experiments/_fixtures/invalid/BadName/program.md +0 -3
  27. package/atris/experiments/_fixtures/invalid/BadName/results.tsv +0 -1
  28. package/atris/experiments/_fixtures/invalid/bloated-context/loop.py +0 -1
  29. package/atris/experiments/_fixtures/invalid/bloated-context/measure.py +0 -1
  30. package/atris/experiments/_fixtures/invalid/bloated-context/program.md +0 -6
  31. package/atris/experiments/_fixtures/invalid/bloated-context/results.tsv +0 -1
  32. package/atris/experiments/_fixtures/valid/good-experiment/loop.py +0 -1
  33. package/atris/experiments/_fixtures/valid/good-experiment/measure.py +0 -1
  34. package/atris/experiments/_fixtures/valid/good-experiment/program.md +0 -3
  35. package/atris/experiments/_fixtures/valid/good-experiment/results.tsv +0 -1
  36. package/atris/experiments/_template/pack/loop.py +0 -3
  37. package/atris/experiments/_template/pack/measure.py +0 -13
  38. package/atris/experiments/_template/pack/program.md +0 -3
  39. package/atris/experiments/_template/pack/reset.py +0 -3
  40. package/atris/experiments/_template/pack/results.tsv +0 -1
  41. package/atris/experiments/benchmark_runtime.py +0 -81
  42. package/atris/experiments/benchmark_validate.py +0 -70
  43. package/atris/experiments/validate.py +0 -92
  44. package/atris/team/navigator/journal/2026-02-23.md +0 -6
package/commands/push.js CHANGED
@@ -3,21 +3,29 @@ const path = require('path');
3
3
  const { loadCredentials } = require('../utils/auth');
4
4
  const { apiRequestJson } = require('../utils/api');
5
5
  const { loadBusinesses, saveBusinesses } = require('./business');
6
+ const { loadManifest, saveManifest, computeFileHash, buildManifest, computeLocalHashes, threeWayCompare, SKIP_DIRS } = require('../lib/manifest');
6
7
 
7
8
  async function pushAtris() {
8
9
  const slug = process.argv[3];
9
10
 
10
11
  if (!slug || slug === '--help') {
11
- console.log('Usage: atris push <business-slug> [--from <path>]');
12
+ console.log('Usage: atris push <business-slug> [--from <path>] [--force]');
12
13
  console.log('');
13
14
  console.log('Push local files to a Business Computer.');
14
15
  console.log('');
16
+ console.log('Options:');
17
+ console.log(' --from <path> Push from a custom directory');
18
+ console.log(' --force Push everything, overwrite conflicts');
19
+ console.log('');
15
20
  console.log('Examples:');
16
21
  console.log(' atris push pallet Push from atris/pallet/ or ./pallet/');
17
22
  console.log(' atris push pallet --from ./my-dir/ Push from a custom directory');
23
+ console.log(' atris push pallet --force Override conflicts');
18
24
  process.exit(0);
19
25
  }
20
26
 
27
+ const force = process.argv.includes('--force');
28
+
21
29
  const creds = loadCredentials();
22
30
  if (!creds || !creds.token) {
23
31
  console.error('Not logged in. Run: atris login');
@@ -50,15 +58,15 @@ async function pushAtris() {
50
58
  }
51
59
 
52
60
  // Resolve business ID
53
- let businessId, workspaceId, businessName;
61
+ let businessId, workspaceId, businessName, resolvedSlug;
54
62
  const businesses = loadBusinesses();
55
63
 
56
64
  if (businesses[slug]) {
57
65
  businessId = businesses[slug].business_id;
58
66
  workspaceId = businesses[slug].workspace_id;
59
67
  businessName = businesses[slug].name || slug;
68
+ resolvedSlug = businesses[slug].slug || slug;
60
69
  } else {
61
- // Try to find by slug via API
62
70
  const listResult = await apiRequestJson('/businesses/', { method: 'GET', token: creds.token });
63
71
  if (!listResult.ok) {
64
72
  console.error(`Failed to fetch businesses: ${listResult.errorMessage || listResult.status}`);
@@ -74,8 +82,8 @@ async function pushAtris() {
74
82
  businessId = match.id;
75
83
  workspaceId = match.workspace_id;
76
84
  businessName = match.name;
85
+ resolvedSlug = match.slug;
77
86
 
78
- // Auto-save
79
87
  businesses[slug] = {
80
88
  business_id: businessId,
81
89
  workspace_id: workspaceId,
@@ -91,80 +99,161 @@ async function pushAtris() {
91
99
  process.exit(1);
92
100
  }
93
101
 
94
- // Walk local directory and collect files
95
- const files = [];
96
- const SKIP_DIRS = new Set(['node_modules', '__pycache__', '.git', 'venv', '.venv', 'lost+found', '.cache']);
97
-
98
- function walkDir(dir) {
99
- const entries = fs.readdirSync(dir, { withFileTypes: true });
100
- for (const entry of entries) {
101
- if (entry.name.startsWith('.')) continue;
102
- const fullPath = path.join(dir, entry.name);
103
-
104
- if (entry.isDirectory()) {
105
- if (SKIP_DIRS.has(entry.name)) continue;
106
- walkDir(fullPath);
107
- } else if (entry.isFile()) {
108
- const relPath = '/' + path.relative(sourceDir, fullPath);
109
- try {
110
- const content = fs.readFileSync(fullPath, 'utf8');
111
- files.push({ path: relPath, content });
112
- } catch {
113
- // Skip binary files
114
- }
115
- }
116
- }
117
- }
102
+ // Load manifest (last sync state)
103
+ const manifest = loadManifest(resolvedSlug || slug);
118
104
 
119
- walkDir(sourceDir);
105
+ // Compute local file hashes
106
+ const localFiles = computeLocalHashes(sourceDir);
120
107
 
121
- if (files.length === 0) {
108
+ if (Object.keys(localFiles).length === 0) {
122
109
  console.log(`\nNo files to push from ${sourceDir}`);
123
110
  return;
124
111
  }
125
112
 
113
+ // Get remote snapshot for three-way compare
126
114
  console.log('');
127
- console.log(`Pushing ${files.length} files to ${businessName}...`);
128
-
129
- // Sync one API call pushes everything
130
- const result = await apiRequestJson(
131
- `/businesses/${businessId}/workspaces/${workspaceId}/sync`,
132
- {
133
- method: 'POST',
134
- token: creds.token,
135
- body: { files },
136
- }
115
+ console.log(`Pushing to ${businessName}...`);
116
+
117
+ const snapshotResult = await apiRequestJson(
118
+ `/businesses/${businessId}/workspaces/${workspaceId}/snapshot?include_content=false`,
119
+ { method: 'GET', token: creds.token }
137
120
  );
138
121
 
139
- if (!result.ok) {
140
- const msg = result.errorMessage || `HTTP ${result.status}`;
141
- if (result.status === 409) {
142
- console.error(`\nComputer is sleeping. Wake it first, then push.`);
143
- } else if (result.status === 403) {
144
- console.error(`\nAccess denied: ${msg}`);
145
- } else {
146
- console.error(`\nPush failed: ${msg}`);
122
+ let remoteFiles = {};
123
+ if (snapshotResult.ok && snapshotResult.data && snapshotResult.data.files) {
124
+ for (const file of snapshotResult.data.files) {
125
+ if (file.path && !file.binary) {
126
+ remoteFiles[file.path] = { hash: file.hash, size: file.size || 0 };
127
+ }
147
128
  }
148
- process.exit(1);
149
129
  }
150
130
 
151
- const data = result.data;
152
- console.log('');
153
- if (data.written > 0) {
154
- console.log(` ${data.written} file${data.written > 1 ? 's' : ''} written`);
131
+ // Three-way compare
132
+ const diff = threeWayCompare(localFiles, remoteFiles, manifest);
133
+
134
+ // Determine what to push
135
+ const filesToPush = [];
136
+
137
+ // Files we changed that remote didn't
138
+ for (const p of [...diff.toPush, ...diff.newLocal]) {
139
+ const localPath = path.join(sourceDir, p.replace(/^\//, ''));
140
+ try {
141
+ const content = fs.readFileSync(localPath, 'utf8');
142
+ filesToPush.push({ path: p, content });
143
+ } catch {
144
+ // skip
145
+ }
146
+ }
147
+
148
+ // Force mode: also push conflicts
149
+ const conflictPaths = [];
150
+ if (force) {
151
+ for (const p of diff.conflicts) {
152
+ const localPath = path.join(sourceDir, p.replace(/^\//, ''));
153
+ try {
154
+ const content = fs.readFileSync(localPath, 'utf8');
155
+ filesToPush.push({ path: p, content });
156
+ } catch {
157
+ // skip
158
+ }
159
+ }
160
+ } else {
161
+ for (const p of diff.conflicts) {
162
+ conflictPaths.push(p);
163
+ }
155
164
  }
156
- if (data.unchanged > 0) {
157
- console.log(` ${data.unchanged} unchanged`);
165
+
166
+ console.log('');
167
+
168
+ if (filesToPush.length === 0 && conflictPaths.length === 0) {
169
+ console.log(' Already up to date.');
170
+ console.log('');
171
+ return;
158
172
  }
159
- if (data.errors > 0) {
160
- console.log(` ${data.errors} error${data.errors > 1 ? 's' : ''}`);
161
- for (const r of (data.results || [])) {
162
- if (r.status === 'error') {
163
- console.log(` ${r.path}: ${r.error}`);
173
+
174
+ // Push the files
175
+ let pushed = 0;
176
+ if (filesToPush.length > 0) {
177
+ const result = await apiRequestJson(
178
+ `/businesses/${businessId}/workspaces/${workspaceId}/sync`,
179
+ {
180
+ method: 'POST',
181
+ token: creds.token,
182
+ body: { files: filesToPush },
183
+ headers: { 'X-Atris-Actor-Source': 'cli' },
184
+ }
185
+ );
186
+
187
+ if (!result.ok) {
188
+ const msg = result.errorMessage || `HTTP ${result.status}`;
189
+ if (result.status === 409) {
190
+ console.error(` Computer is sleeping. Wake it first, then push.`);
191
+ } else if (result.status === 403) {
192
+ console.error(` Access denied: ${msg}`);
193
+ } else {
194
+ console.error(` Push failed: ${msg}`);
195
+ }
196
+ process.exit(1);
197
+ }
198
+
199
+ // Display results
200
+ for (const p of diff.toPush) {
201
+ console.log(` \u2191 ${p.replace(/^\//, '')} pushing your changes`);
202
+ pushed++;
203
+ }
204
+ for (const p of diff.newLocal) {
205
+ console.log(` + ${p.replace(/^\//, '')} new file`);
206
+ pushed++;
207
+ }
208
+ if (force) {
209
+ for (const p of diff.conflicts) {
210
+ console.log(` ! ${p.replace(/^\//, '')} overwritten (--force)`);
211
+ pushed++;
164
212
  }
165
213
  }
166
214
  }
167
- console.log(`\n Synced to ${businessName}.`);
215
+
216
+ // Show conflicts
217
+ for (const p of conflictPaths) {
218
+ console.log(` \u26A0 ${p.replace(/^\//, '')} CONFLICT \u2014 skipped (use --force to override)`);
219
+ }
220
+
221
+ // Show unchanged
222
+ if (diff.unchanged.length > 0) {
223
+ // Don't list them all, just count
224
+ }
225
+
226
+ // Summary
227
+ console.log('');
228
+ const parts = [];
229
+ if (pushed > 0) parts.push(`${pushed} pushed`);
230
+ if (diff.unchanged.length > 0) parts.push(`${diff.unchanged.length} unchanged`);
231
+ if (conflictPaths.length > 0) parts.push(`${conflictPaths.length} conflict${conflictPaths.length > 1 ? 's' : ''}`);
232
+ if (parts.length > 0) console.log(` ${parts.join(', ')}.`);
233
+
234
+ // Get commit hash after push
235
+ let commitHash = null;
236
+ try {
237
+ const headResult = await apiRequestJson(
238
+ `/businesses/${businessId}/workspaces/${workspaceId}/git/head`,
239
+ { method: 'GET', token: creds.token }
240
+ );
241
+ if (headResult.ok && headResult.data && headResult.data.commit) {
242
+ commitHash = headResult.data.commit;
243
+ }
244
+ } catch {
245
+ // Git might not be initialized yet
246
+ }
247
+
248
+ // Update manifest with new state (merge local + remote)
249
+ const mergedFiles = { ...remoteFiles };
250
+ for (const p of Object.keys(localFiles)) {
251
+ if (filesToPush.some(f => f.path === p)) {
252
+ mergedFiles[p] = localFiles[p]; // we pushed this, so our hash is now the truth
253
+ }
254
+ }
255
+ const newManifest = buildManifest(mergedFiles, commitHash);
256
+ saveManifest(resolvedSlug || slug, newManifest);
168
257
  }
169
258
 
170
259
  module.exports = { pushAtris };
@@ -0,0 +1,222 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+ const { computeContentHash } = require('./journal');
5
+
6
+ /**
7
+ * Get the manifest file path for a business slug.
8
+ * Stored at ~/.atris/businesses/{slug}/manifest.json
9
+ */
10
+ function getManifestPath(slug) {
11
+ return path.join(os.homedir(), '.atris', 'businesses', slug, 'manifest.json');
12
+ }
13
+
14
+ /**
15
+ * Load the manifest for a business, or null if no previous sync.
16
+ */
17
+ function loadManifest(slug) {
18
+ const p = getManifestPath(slug);
19
+ if (!fs.existsSync(p)) return null;
20
+ try {
21
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Save a manifest after a successful sync.
29
+ */
30
+ function saveManifest(slug, manifest) {
31
+ const p = getManifestPath(slug);
32
+ const dir = path.dirname(p);
33
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
34
+ fs.writeFileSync(p, JSON.stringify(manifest, null, 2));
35
+ }
36
+
37
+ /**
38
+ * Compute SHA-256 hash of normalized content.
39
+ * Delegates to journal.js computeContentHash.
40
+ */
41
+ function computeFileHash(content) {
42
+ return computeContentHash(content);
43
+ }
44
+
45
+ /**
46
+ * Build a manifest object from a set of files.
47
+ * files: { [path]: { hash, size } }
48
+ */
49
+ function buildManifest(files, commitHash) {
50
+ return {
51
+ last_sync: new Date().toISOString(),
52
+ last_commit: commitHash || null,
53
+ files,
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Walk a local directory and compute hashes for all text files.
59
+ * Returns: { "/path": { hash, size } }
60
+ */
61
+ const SKIP_DIRS = new Set([
62
+ 'node_modules', '__pycache__', '.git', 'venv', '.venv',
63
+ 'lost+found', '.cache', '.atris',
64
+ ]);
65
+
66
+ function computeLocalHashes(localDir) {
67
+ const files = {};
68
+
69
+ function walk(dir) {
70
+ let entries;
71
+ try {
72
+ entries = fs.readdirSync(dir, { withFileTypes: true });
73
+ } catch {
74
+ return;
75
+ }
76
+ for (const entry of entries) {
77
+ if (entry.name.startsWith('.')) continue;
78
+ const fullPath = path.join(dir, entry.name);
79
+ if (entry.isDirectory()) {
80
+ if (SKIP_DIRS.has(entry.name)) continue;
81
+ walk(fullPath);
82
+ } else if (entry.isFile()) {
83
+ const relPath = '/' + path.relative(localDir, fullPath);
84
+ try {
85
+ const content = fs.readFileSync(fullPath, 'utf8');
86
+ const hash = computeFileHash(content);
87
+ files[relPath] = { hash, size: Buffer.byteLength(content) };
88
+ } catch {
89
+ // skip binary or unreadable
90
+ }
91
+ }
92
+ }
93
+ }
94
+
95
+ walk(localDir);
96
+ return files;
97
+ }
98
+
99
+ /**
100
+ * Three-way comparison between local files, remote files, and the last-synced manifest.
101
+ *
102
+ * localFiles: { "/path": { hash, size } }
103
+ * remoteFiles: { "/path": { hash, size } }
104
+ * manifest: { files: { "/path": { hash, size } } } or null (first sync)
105
+ *
106
+ * Returns: { toPull, toPush, conflicts, unchanged, deletedLocal, deletedRemote, newLocal, newRemote }
107
+ * Each array contains file path strings.
108
+ */
109
+ function threeWayCompare(localFiles, remoteFiles, manifest) {
110
+ const result = {
111
+ toPull: [],
112
+ toPush: [],
113
+ conflicts: [],
114
+ unchanged: [],
115
+ deletedLocal: [],
116
+ deletedRemote: [],
117
+ newLocal: [],
118
+ newRemote: [],
119
+ };
120
+
121
+ // First sync — no manifest
122
+ if (!manifest || !manifest.files) {
123
+ // Everything remote is "new from remote"
124
+ for (const p of Object.keys(remoteFiles)) {
125
+ if (localFiles[p] && localFiles[p].hash === remoteFiles[p].hash) {
126
+ result.unchanged.push(p);
127
+ } else if (localFiles[p]) {
128
+ // Both exist with different hashes, no baseline — treat as conflict
129
+ result.conflicts.push(p);
130
+ } else {
131
+ result.newRemote.push(p);
132
+ }
133
+ }
134
+ // Local files not in remote are new local
135
+ for (const p of Object.keys(localFiles)) {
136
+ if (!remoteFiles[p]) {
137
+ result.newLocal.push(p);
138
+ }
139
+ }
140
+ return result;
141
+ }
142
+
143
+ const base = manifest.files;
144
+ const allPaths = new Set([
145
+ ...Object.keys(localFiles),
146
+ ...Object.keys(remoteFiles),
147
+ ...Object.keys(base),
148
+ ]);
149
+
150
+ for (const p of allPaths) {
151
+ const inLocal = p in localFiles;
152
+ const inRemote = p in remoteFiles;
153
+ const inBase = p in base;
154
+
155
+ const localHash = inLocal ? localFiles[p].hash : null;
156
+ const remoteHash = inRemote ? remoteFiles[p].hash : null;
157
+ const baseHash = inBase ? base[p].hash : null;
158
+
159
+ if (inLocal && inRemote && inBase) {
160
+ // All three exist — standard three-way
161
+ const localChanged = localHash !== baseHash;
162
+ const remoteChanged = remoteHash !== baseHash;
163
+
164
+ if (!localChanged && !remoteChanged) {
165
+ result.unchanged.push(p);
166
+ } else if (!localChanged && remoteChanged) {
167
+ result.toPull.push(p);
168
+ } else if (localChanged && !remoteChanged) {
169
+ result.toPush.push(p);
170
+ } else {
171
+ // Both changed
172
+ if (localHash === remoteHash) {
173
+ // Both changed to the same thing
174
+ result.unchanged.push(p);
175
+ } else {
176
+ result.conflicts.push(p);
177
+ }
178
+ }
179
+ } else if (inRemote && !inBase && !inLocal) {
180
+ // New on remote
181
+ result.newRemote.push(p);
182
+ } else if (inLocal && !inBase && !inRemote) {
183
+ // New locally
184
+ result.newLocal.push(p);
185
+ } else if (inBase && !inRemote && inLocal) {
186
+ // Was in base, deleted on remote, still local
187
+ result.deletedRemote.push(p);
188
+ } else if (inBase && !inLocal && inRemote) {
189
+ // Was in base, deleted locally, still remote
190
+ result.deletedLocal.push(p);
191
+ } else if (inBase && !inLocal && !inRemote) {
192
+ // Deleted on both sides — nothing to do
193
+ // (don't add to any list)
194
+ } else if (inLocal && inRemote && !inBase) {
195
+ // Both sides have it but no base — new on both
196
+ if (localHash === remoteHash) {
197
+ result.unchanged.push(p);
198
+ } else {
199
+ result.conflicts.push(p);
200
+ }
201
+ } else if (inRemote && inBase && !inLocal) {
202
+ // Deleted locally but remote still has it (and maybe changed)
203
+ result.deletedLocal.push(p);
204
+ } else if (inLocal && inBase && !inRemote) {
205
+ // Deleted on remote but local still has it (and maybe changed)
206
+ result.deletedRemote.push(p);
207
+ }
208
+ }
209
+
210
+ return result;
211
+ }
212
+
213
+ module.exports = {
214
+ loadManifest,
215
+ saveManifest,
216
+ computeFileHash,
217
+ buildManifest,
218
+ computeLocalHashes,
219
+ threeWayCompare,
220
+ getManifestPath,
221
+ SKIP_DIRS,
222
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "2.5.5",
3
+ "version": "2.6.1",
4
4
  "description": "atrisDev (atris dev) - CLI for AI coding agents. Works with Claude Code, Cursor, Windsurf. Make any codebase AI-navigable.",
5
5
  "main": "bin/atris.js",
6
6
  "bin": {
@@ -12,7 +12,6 @@
12
12
  "utils/",
13
13
  "lib/",
14
14
  "README.md",
15
- "AGENT.md",
16
15
  "AGENTS.md",
17
16
  "atris.md",
18
17
  "GETTING_STARTED.md",
@@ -20,8 +19,14 @@
20
19
  "atris/atrisDev.md",
21
20
  "atris/CLAUDE.md",
22
21
  "atris/GEMINI.md",
23
- "atris/team/",
24
- "atris/experiments/",
22
+ "atris/GETTING_STARTED.md",
23
+ "atris/team/navigator/MEMBER.md",
24
+ "atris/team/executor/MEMBER.md",
25
+ "atris/team/validator/MEMBER.md",
26
+ "atris/team/brainstormer/MEMBER.md",
27
+ "atris/team/launcher/MEMBER.md",
28
+ "atris/team/researcher/MEMBER.md",
29
+ "atris/team/_template/MEMBER.md",
25
30
  "atris/features/_templates/",
26
31
  "atris/policies/",
27
32
  "atris/skills/"
package/utils/auth.js CHANGED
@@ -124,6 +124,101 @@ function getCredentialsPath() {
124
124
  return path.join(getAtrisDir(), 'credentials.json');
125
125
  }
126
126
 
127
+ function getSessionsDir() {
128
+ const dir = path.join(getAtrisDir(), 'sessions');
129
+ if (!fs.existsSync(dir)) {
130
+ fs.mkdirSync(dir, { recursive: true });
131
+ }
132
+ return dir;
133
+ }
134
+
135
+ function getTerminalSessionId() {
136
+ // Unique per terminal window/tab — works across macOS terminals, tmux, VS Code, Ghostty
137
+ const envId = process.env.TERM_SESSION_ID // macOS Terminal.app
138
+ || process.env.ITERM_SESSION_ID // iTerm2
139
+ || process.env.TMUX_PANE // tmux pane
140
+ || process.env.WT_SESSION // Windows Terminal
141
+ || process.env.WEZTERM_PANE; // WezTerm
142
+ if (envId) return envId;
143
+
144
+ // Universal fallback: TTY device name (unique per terminal tab on macOS/Linux)
145
+ // Each Ghostty/iTerm/Terminal tab gets a unique /dev/ttysNNN
146
+ try {
147
+ // Method 1: check if stdin is a TTY and resolve its path
148
+ if (process.stdin.isTTY) {
149
+ const resolved = fs.realpathSync('/dev/stdin');
150
+ if (resolved && resolved.startsWith('/dev/tty')) return resolved;
151
+ }
152
+ } catch {}
153
+
154
+ try {
155
+ // Method 2: shell out to tty command
156
+ const { execSync } = require('child_process');
157
+ const tty = execSync('tty', { encoding: 'utf8', stdio: ['inherit', 'pipe', 'pipe'] }).trim();
158
+ if (tty && tty !== 'not a tty' && tty.startsWith('/dev/')) return tty;
159
+ } catch {}
160
+
161
+ return null;
162
+ }
163
+
164
+ function sanitizeSessionId(id) {
165
+ // Make filesystem-safe: replace non-alphanumeric with dashes, truncate
166
+ return id.replace(/[^a-zA-Z0-9_-]/g, '-').slice(0, 64);
167
+ }
168
+
169
+ function getSessionFilePath() {
170
+ const sessionId = getTerminalSessionId();
171
+ if (!sessionId) return null;
172
+ return path.join(getSessionsDir(), `${sanitizeSessionId(sessionId)}.json`);
173
+ }
174
+
175
+ function setSessionProfile(profileName) {
176
+ const sessionPath = getSessionFilePath();
177
+ if (!sessionPath) {
178
+ // No terminal session ID — fall back to global switch
179
+ return false;
180
+ }
181
+ fs.writeFileSync(sessionPath, JSON.stringify({
182
+ profile: profileName,
183
+ set_at: new Date().toISOString(),
184
+ }));
185
+ return true;
186
+ }
187
+
188
+ function getSessionProfile() {
189
+ const sessionPath = getSessionFilePath();
190
+ if (!sessionPath || !fs.existsSync(sessionPath)) return null;
191
+ try {
192
+ const data = JSON.parse(fs.readFileSync(sessionPath, 'utf8'));
193
+ return data.profile || null;
194
+ } catch {
195
+ return null;
196
+ }
197
+ }
198
+
199
+ function clearSessionProfile() {
200
+ const sessionPath = getSessionFilePath();
201
+ if (sessionPath && fs.existsSync(sessionPath)) {
202
+ fs.unlinkSync(sessionPath);
203
+ }
204
+ }
205
+
206
+ function cleanStaleSessions() {
207
+ // Remove session files older than 7 days
208
+ const dir = path.join(getAtrisDir(), 'sessions');
209
+ if (!fs.existsSync(dir)) return;
210
+ const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000;
211
+ try {
212
+ for (const f of fs.readdirSync(dir)) {
213
+ const fp = path.join(dir, f);
214
+ try {
215
+ const stat = fs.statSync(fp);
216
+ if (stat.mtimeMs < cutoff) fs.unlinkSync(fp);
217
+ } catch {}
218
+ }
219
+ } catch {}
220
+ }
221
+
127
222
  function getProfilesDir() {
128
223
  const dir = path.join(getAtrisDir(), 'profiles');
129
224
  if (!fs.existsSync(dir)) {
@@ -205,6 +300,32 @@ function saveCredentials(token, refreshToken, email, userId, provider) {
205
300
  }
206
301
 
207
302
  function loadCredentials() {
303
+ // Priority: ATRIS_PROFILE env var → per-terminal session file → global credentials.json
304
+
305
+ // 1. Explicit env var override
306
+ const profileOverride = process.env.ATRIS_PROFILE;
307
+ if (profileOverride) {
308
+ const profile = loadProfile(profileOverride);
309
+ if (profile) return profile;
310
+ const profiles = listProfiles();
311
+ const q = profileOverride.toLowerCase();
312
+ const match = profiles.find(p => p.toLowerCase() === q)
313
+ || profiles.find(p => p.toLowerCase().startsWith(q))
314
+ || profiles.find(p => p.toLowerCase().includes(q));
315
+ if (match) {
316
+ const matched = loadProfile(match);
317
+ if (matched) return matched;
318
+ }
319
+ }
320
+
321
+ // 2. Per-terminal session override (set by atris switch)
322
+ const sessionProfile = getSessionProfile();
323
+ if (sessionProfile) {
324
+ const profile = loadProfile(sessionProfile);
325
+ if (profile) return profile;
326
+ }
327
+
328
+ // 3. Global credentials.json
208
329
  const credentialsPath = getCredentialsPath();
209
330
 
210
331
  if (!fs.existsSync(credentialsPath)) {
@@ -456,4 +577,10 @@ module.exports = {
456
577
  deleteProfile,
457
578
  profileNameFromEmail,
458
579
  autoSaveProfile,
580
+ // Per-terminal sessions
581
+ getTerminalSessionId,
582
+ setSessionProfile,
583
+ getSessionProfile,
584
+ clearSessionProfile,
585
+ cleanStaleSessions,
459
586
  };