@bahulam/code 2.6.14 → 2.6.16

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.
@@ -28,6 +28,18 @@ const PROTECTED_NAMES = new Set([
28
28
  'go.sum',
29
29
  ]);
30
30
 
31
+ const APPROVAL_REQUIRED_WRITE_NAMES = new Set([
32
+ '.env',
33
+ '.env.local',
34
+ '.env.production',
35
+ 'package.json',
36
+ 'package-lock.json',
37
+ 'yarn.lock',
38
+ 'pnpm-lock.yaml',
39
+ 'Cargo.lock',
40
+ 'go.sum',
41
+ ]);
42
+
31
43
  /** Directory names that indicate a source root — never delete the dir itself. */
32
44
  const SOURCE_DIRS = new Set([
33
45
  'src',
@@ -80,13 +92,35 @@ const HIGH_RISK_COMMANDS = [
80
92
 
81
93
  // ── Validation Functions ─────────────────────────────────────
82
94
 
95
+ function basenameFor(filePath = '') {
96
+ return path.basename(String(filePath || '').replace(/\\/g, '/'));
97
+ }
98
+
99
+ function isEnvLikeName(name = '') {
100
+ return /^\.env(?:\.|$)/.test(String(name || ''));
101
+ }
102
+
103
+ function isProtectedName(name = '') {
104
+ return PROTECTED_NAMES.has(name) || isEnvLikeName(name);
105
+ }
106
+
107
+ export function isSensitiveConfigPath(filePath = '') {
108
+ return isEnvLikeName(basenameFor(filePath));
109
+ }
110
+
111
+ export function isApprovalRequiredWritePath(filePath = '') {
112
+ const basename = basenameFor(filePath);
113
+ return isSensitiveConfigPath(filePath) || APPROVAL_REQUIRED_WRITE_NAMES.has(basename);
114
+ }
115
+
83
116
  /**
84
117
  * Check if a path is protected (should not be deleted/overwritten entirely).
85
118
  * @param {string} filePath - Absolute path
86
119
  * @param {string} cwd - Current working directory
87
- * @returns {{ safe: boolean, reason?: string }}
120
+ * @param {{ allowApprovalRequiredWrite?: boolean }} options
121
+ * @returns {{ safe: boolean, reason?: string, requiresApproval?: boolean, sensitive?: boolean, protected?: boolean }}
88
122
  */
89
- export function validatePath(filePath, cwd = process.cwd()) {
123
+ export function validatePath(filePath, cwd = process.cwd(), options = {}) {
90
124
  if (!filePath) return { safe: false, reason: 'Empty path' };
91
125
 
92
126
  const resolved = path.resolve(cwd, filePath);
@@ -100,7 +134,16 @@ export function validatePath(filePath, cwd = process.cwd()) {
100
134
  }
101
135
 
102
136
  // Block protected file names at any level
103
- if (PROTECTED_NAMES.has(basename) && !resolved.includes('node_modules/')) {
137
+ if (isProtectedName(basename) && !resolved.includes('node_modules/')) {
138
+ if (options.allowApprovalRequiredWrite && isApprovalRequiredWritePath(resolved)) {
139
+ return {
140
+ safe: true,
141
+ reason: `Approval required for protected path: ${basename}`,
142
+ requiresApproval: true,
143
+ protected: true,
144
+ sensitive: isSensitiveConfigPath(resolved),
145
+ };
146
+ }
104
147
  return { safe: false, reason: `Protected path: ${basename}` };
105
148
  }
106
149
 
@@ -172,16 +215,29 @@ export function validateShellCommand(command) {
172
215
  * @returns {{ safe: boolean, reason?: string }}
173
216
  */
174
217
  export function validateWrite(filePath, content, cwd = process.cwd()) {
175
- const pathCheck = validatePath(filePath, cwd);
218
+ const pathCheck = validatePath(filePath, cwd, { allowApprovalRequiredWrite: true });
176
219
  if (!pathCheck.safe) return pathCheck;
177
220
 
178
221
  const resolved = path.resolve(cwd, filePath);
222
+ const basename = path.basename(resolved);
179
223
 
180
224
  // Don't allow overwriting .git internals
181
225
  if (resolved.includes('/.git/')) {
182
226
  return { safe: false, reason: 'Cannot write to .git directory' };
183
227
  }
184
228
 
229
+ if (pathCheck.requiresApproval) {
230
+ return {
231
+ safe: true,
232
+ reason: pathCheck.sensitive
233
+ ? `Sensitive config write requires approval: ${basename}`
234
+ : `Protected file write requires approval: ${basename}`,
235
+ requiresApproval: true,
236
+ protected: true,
237
+ sensitive: pathCheck.sensitive,
238
+ };
239
+ }
240
+
185
241
  // Warn if writing a very large file (> 1MB)
186
242
  if (content && content.length > 1_000_000) {
187
243
  return { safe: true, reason: `Large file write: ${(content.length / 1024).toFixed(0)}KB` };
@@ -196,6 +252,7 @@ export function validateWrite(filePath, content, cwd = process.cwd()) {
196
252
  export function getSafetyRules() {
197
253
  return {
198
254
  protectedNames: [...PROTECTED_NAMES],
255
+ approvalRequiredWriteNames: [...APPROVAL_REQUIRED_WRITE_NAMES],
199
256
  sourceDirs: [...SOURCE_DIRS],
200
257
  blockedPatterns: DANGEROUS_SHELL_PATTERNS.length,
201
258
  highRiskPatterns: HIGH_RISK_COMMANDS.length,
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { createToolRegistry } from '../tools/registry.mjs';
12
12
  import { detectCommandType, filterOutput } from './output-filter.mjs';
13
- import { validatePath, validateDelete, validateShellCommand, validateWrite } from './safety.mjs';
13
+ import { isSensitiveConfigPath, validatePath, validateDelete, validateShellCommand, validateWrite } from './safety.mjs';
14
14
  import { classifyCommand, isExitCodeError } from '../permissions/command-classifier.mjs';
15
15
  import { analyzeCode } from '../context/ast-parser.mjs';
16
16
  import { ProjectRegistry } from '../tools/project-overview.mjs';
@@ -366,18 +366,35 @@ export function createToolExecutor({
366
366
  return result;
367
367
  }
368
368
 
369
+ function buildResultFileDiff(filePath, before, after) {
370
+ const diff = buildFileDiff({
371
+ filePath,
372
+ before,
373
+ after,
374
+ cwd: projectRootFor(filePath),
375
+ });
376
+ if (!isSensitiveConfigPath(filePath)) return diff;
377
+ return {
378
+ ...diff,
379
+ hunks: [],
380
+ unified: '',
381
+ redacted: true,
382
+ sensitive: true,
383
+ redaction_reason: 'Sensitive config diff redacted',
384
+ };
385
+ }
386
+
369
387
  function attachFileDiff(result, filePath, before, after) {
370
388
  try {
371
- const diff = buildFileDiff({
372
- filePath,
373
- before,
374
- after,
375
- cwd: projectRootFor(filePath),
376
- });
389
+ const diff = buildResultFileDiff(filePath, before, after);
377
390
  result.file_diff = diff;
378
391
  result.diff = diff.unified;
379
392
  result.lines_added = diff.lines_added;
380
393
  result.lines_removed = diff.lines_removed;
394
+ if (diff.redacted) {
395
+ result.output = `File updated: ${diff.relative_path || filePath}\nDiff redacted for sensitive config file.`;
396
+ result.redacted = true;
397
+ }
381
398
  } catch { /* best effort */ }
382
399
  return result;
383
400
  }
@@ -821,12 +838,7 @@ export function createToolExecutor({
821
838
 
822
839
  await occRegistry.call('Write', { file_path: filePath, content });
823
840
  const after = readTextIfExists(filePath);
824
- diffs.push(buildFileDiff({
825
- filePath,
826
- before,
827
- after,
828
- cwd: projectRootFor(filePath),
829
- }));
841
+ diffs.push(buildResultFileDiff(filePath, before, after));
830
842
  updateProjectIndex(filePath);
831
843
  results.push(rawPath);
832
844
  } catch (err) {
@@ -96,11 +96,13 @@ export class TrustStore {
96
96
  }
97
97
  }
98
98
  if ((reask.on_risk_increase ?? this.policy.hitl?.reaskOnRiskIncrease) && rule.tier && tier) {
99
- const order = [TIERS.READ, TIERS.SHELL_SAFE, TIERS.LOCAL_EDIT, TIERS.SHELL_MEDIUM, TIERS.NETWORK, TIERS.SHELL_DANGEROUS, TIERS.DESTRUCTIVE];
99
+ const order = [TIERS.READ, TIERS.SHELL_SAFE, TIERS.LOCAL_EDIT, TIERS.PROTECTED_EDIT, TIERS.SHELL_MEDIUM, TIERS.NETWORK, TIERS.SHELL_DANGEROUS, TIERS.DESTRUCTIVE];
100
100
  if (order.indexOf(tier) > order.indexOf(rule.tier)) return 'risk tier increased';
101
101
  }
102
- if ((this.policy.hitl?.alwaysAskForDangerous ?? true) && (tier === TIERS.SHELL_DANGEROUS || tier === TIERS.DESTRUCTIVE)) {
103
- return 'dangerous tier requires fresh approval';
102
+ if ((this.policy.hitl?.alwaysAskForDangerous ?? true) && (tier === TIERS.SHELL_DANGEROUS || tier === TIERS.DESTRUCTIVE || tier === TIERS.PROTECTED_EDIT)) {
103
+ return tier === TIERS.PROTECTED_EDIT
104
+ ? 'protected edit requires fresh approval'
105
+ : 'dangerous tier requires fresh approval';
104
106
  }
105
107
  if ((reask.on_command_shape_change ?? this.policy.hitl?.reaskOnCommandShapeChange) && rule.command_shape && args.command) {
106
108
  if (rule.command_shape !== commandShape(args.command)) return 'command shape changed';
package/src/index.mjs CHANGED
@@ -35,7 +35,7 @@ import { printBanner, printProjectInfo, printHints, printAuthStatus, printStyled
35
35
  import { ContextRetriever } from './context/retriever.mjs';
36
36
  import { loadSettings } from './config/settings.mjs';
37
37
 
38
- const VERSION = '2.6.14';
38
+ const VERSION = '2.6.16';
39
39
 
40
40
  // ── Arg Parsing (consolidated from index.mjs + cli-args.mjs) ──
41
41
 
@@ -50,6 +50,7 @@ function parseArgs(argv) {
50
50
  // Config subcommand flags
51
51
  showConfig: false, openRouterKey: null, anthropicKey: null,
52
52
  backendUrl: null, mode: null,
53
+ model: null, route: null,
53
54
  // Extended flags (from cli-args.mjs)
54
55
  permissionMode: null,
55
56
  outputFormat: null,
@@ -96,7 +97,8 @@ function parseArgs(argv) {
96
97
  case '--google-key': args.googleKey = argv[++i]; break;
97
98
  case '--gateway': args.gateway = argv[++i]; break;
98
99
  // --backend-url removed: use TARANG_ENV
99
- // --model removed: use tarang configure (web settings)
100
+ case '--model': case '-m': args.model = argv[++i]; break;
101
+ case '--route': args.route = argv[++i]; break;
100
102
  // Extended flags
101
103
  case '--permission-mode': args.permissionMode = argv[++i]; break;
102
104
  case '--print': case '-p': case '--instruction': case '--input': args.instruction = argv[++i]; break;
@@ -175,9 +177,11 @@ function printUsage() {
175
177
  process.stderr.write(` ${D}(default: auto-select based on task complexity)${R}\n`);
176
178
  process.stderr.write('\n');
177
179
  process.stderr.write(`${B}MODEL FLAGS${R}\n`);
180
+ process.stderr.write(` ${G}--model, -m <id|role=id>${R} Session model override ${D}(also: fast|thinking|extra|max)${R}\n`);
181
+ process.stderr.write(` ${G}--route <platform|byok>${R} Model route ${D}(platform validates against curated catalog)${R}\n`);
178
182
  process.stderr.write(` ${G}--system-prompt <text>${R} Override system prompt\n`);
179
183
  process.stderr.write(` ${G}--max-turns <n>${R} Maximum conversation turns\n`);
180
- process.stderr.write(` ${D}Models are configured via: bahulam configure${R}\n`);
184
+ process.stderr.write(` ${D}Persistent defaults are configured via: bahulam configure${R}\n`);
181
185
  process.stderr.write('\n');
182
186
  process.stderr.write(`${B}PERMISSION FLAGS${R}\n`);
183
187
  process.stderr.write(` ${G}--yes, -y${R} Auto-approve all operations\n`);
@@ -132,6 +132,32 @@ function containsUnquotedExpansion(command) {
132
132
  /**
133
133
  * Detect command substitution patterns.
134
134
  */
135
+ /**
136
+ * Extract the inner text of every `$( … )` (nesting-aware) and
137
+ * `` ` … ` `` region so each body can be classified on its own.
138
+ */
139
+ function extractSubstitutionBodies(command) {
140
+ const bodies = [];
141
+ const s = String(command || '');
142
+ for (const m of s.match(/`([^`]*)`/g) || []) {
143
+ bodies.push(m.slice(1, -1));
144
+ }
145
+ for (let i = 0; i < s.length; i++) {
146
+ if (s[i] === '$' && s[i + 1] === '(') {
147
+ let depth = 1;
148
+ let j = i + 2;
149
+ while (j < s.length && depth > 0) {
150
+ if (s[j] === '(') depth++;
151
+ else if (s[j] === ')') depth--;
152
+ j++;
153
+ }
154
+ bodies.push(s.slice(i + 2, depth === 0 ? j - 1 : j));
155
+ i = j - 1;
156
+ }
157
+ }
158
+ return bodies.map(b => b.trim()).filter(Boolean);
159
+ }
160
+
135
161
  function containsCommandSubstitution(command) {
136
162
  // Backticks
137
163
  if (/`/.test(command)) return true;
@@ -505,9 +531,31 @@ export function classifyCommand(command) {
505
531
  }
506
532
  }
507
533
 
508
- // ── Step 2: Check for command substitution / injection ──
534
+ // ── Step 2: Command substitution approval-gated, not hard-blocked ──
535
+ // Backticks / $() are legitimate shell (lsof -ti:8000 | xargs kill,
536
+ // $(git rev-parse HEAD), etc). Hard-blocking them forced the model
537
+ // into awkward multi-step workarounds even after the user approved.
538
+ // The substitution BODY is classified recursively — `echo $(rm -rf /)`
539
+ // stays hard-blocked because the inner command is; a benign body
540
+ // surfaces as an explicit HITL approval (contained + highRisk), same
541
+ // as process cleanup above. (Step 1's whole-string regexes alone are
542
+ // not enough: `rm -rf /` inside $() terminates with `)` which the
543
+ // \s|$ terminators never match.)
509
544
  if (containsCommandSubstitution(trimmed)) {
510
- return { classification: 'blocked', reason: 'Contains command substitution (backticks or $())' };
545
+ for (const inner of extractSubstitutionBodies(trimmed)) {
546
+ const innerResult = classifyCommand(inner);
547
+ if (innerResult.classification === 'blocked') {
548
+ return {
549
+ classification: 'blocked',
550
+ reason: `Substitution body is blocked: ${inner.slice(0, 50)}`,
551
+ };
552
+ }
553
+ }
554
+ return {
555
+ classification: 'contained',
556
+ reason: 'Uses command substitution (backticks or $()); requires approval',
557
+ highRisk: true,
558
+ };
511
559
  }
512
560
 
513
561
  if (containsUnsafeOutputRedirection(trimmed)) {
@@ -1,96 +1,122 @@
1
1
  /**
2
- * Telemetry Stub basic telemetry interface (no actual reporting).
2
+ * Telemetry — Funnel event emitter (PRD-076 W3).
3
3
  *
4
- * Logs events to debug output when CLAUDE_CODE_DEBUG is set.
5
- * Designed to be a drop-in interface for the full telemetry system.
4
+ * Buffers funnel events and flushes to {backend}/api/telemetry/funnel
5
+ * on a timer and on process exit. Device ID is generated once per install
6
+ * for anon-to-logged-in correlation.
7
+ *
8
+ * Opt-out: BAHULAM_DISABLE_TELEMETRY=1 or CLAUDE_CODE_DISABLE_TELEMETRY=1
6
9
  */
7
10
 
8
- const events = [];
11
+ import * as fs from 'node:fs';
12
+ import * as path from 'node:path';
13
+ import * as crypto from 'node:crypto';
14
+ import { bahulamHome } from '../core/paths.mjs';
15
+
16
+ const BUF_LIMIT = 500;
17
+ const FLUSH_INTERVAL_MS = 30_000;
18
+
19
+ let events = [];
9
20
  let enabled = true;
21
+ let flushTimer = null;
22
+ let _backendUrl = null;
23
+ let _token = null;
24
+ let _deviceId = null;
10
25
 
11
- /**
12
- * Track a telemetry event.
13
- * @param {string} event - event name
14
- * @param {object} [properties] - event properties
15
- */
16
- export function track(event, properties = {}) {
17
- if (!enabled) return;
18
- if (process.env.CLAUDE_CODE_DISABLE_TELEMETRY === '1') return;
26
+ /* ── Configuration ── */
19
27
 
20
- const entry = {
21
- event,
22
- properties,
23
- timestamp: Date.now(),
24
- };
28
+ export function disable() {
29
+ enabled = false;
30
+ if (flushTimer) { clearInterval(flushTimer); flushTimer = null; }
31
+ }
25
32
 
26
- events.push(entry);
33
+ export function configure(backendUrl, token) {
34
+ _backendUrl = backendUrl;
35
+ _token = token;
36
+ }
27
37
 
28
- // Keep max 1000 events in memory
29
- if (events.length > 1000) {
30
- events.splice(0, events.length - 1000);
31
- }
38
+ /* ── Device ID ── */
32
39
 
33
- if (process.env.CLAUDE_CODE_DEBUG) {
34
- console.error(`[telemetry] ${event}`, JSON.stringify(properties).slice(0, 200));
40
+ function readOrCreateDeviceId() {
41
+ const dir = bahulamHome();
42
+ const idPath = path.join(dir, 'device_id');
43
+ try {
44
+ if (fs.existsSync(idPath)) {
45
+ return fs.readFileSync(idPath, 'utf-8').trim();
35
46
  }
47
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
48
+ const id = crypto.randomUUID();
49
+ fs.writeFileSync(idPath, id, { mode: 0o600 });
50
+ return id;
51
+ } catch { return 'unknown'; }
52
+ }
53
+
54
+ export function getDeviceId() {
55
+ if (!_deviceId) _deviceId = readOrCreateDeviceId();
56
+ return _deviceId;
57
+ }
58
+
59
+ /* ── Tracking ── */
60
+
61
+ export function track(event, properties = {}) {
62
+ if (!enabled) return;
63
+ if (process.env.BAHULAM_DISABLE_TELEMETRY === '1') return;
64
+ if (process.env.CLAUDE_CODE_DISABLE_TELEMETRY === '1') return;
65
+
66
+ const entry = { event, properties: { ...properties }, device_id: getDeviceId(), timestamp: Date.now() };
67
+ events.push(entry);
68
+ if (events.length > BUF_LIMIT) events.splice(0, events.length - BUF_LIMIT);
69
+
70
+ if (process.env.BAHULAM_DEBUG_TELEMETRY) {
71
+ process.stderr.write(`[telemetry] ${event} ${JSON.stringify(properties).slice(0, 200)}\n`);
72
+ }
73
+
74
+ if (!flushTimer) {
75
+ flushTimer = setInterval(flush, FLUSH_INTERVAL_MS);
76
+ flushTimer.unref();
77
+ }
36
78
  }
37
79
 
38
- /**
39
- * Track a timing event.
40
- * @param {string} event - event name
41
- * @param {number} durationMs - duration in milliseconds
42
- * @param {object} [properties] - additional properties
43
- */
44
80
  export function trackTiming(event, durationMs, properties = {}) {
45
- track(event, { ...properties, durationMs });
81
+ track(event, { ...properties, durationMs });
46
82
  }
47
83
 
48
- /**
49
- * Track an error.
50
- * @param {string} event - error context
51
- * @param {Error} error - the error
52
- */
53
84
  export function trackError(event, error) {
54
- track(`error.${event}`, {
55
- message: error.message,
56
- stack: error.stack?.split('\n').slice(0, 3).join('\n'),
57
- });
85
+ track(`error.${event}`, { message: error.message, stack: error.stack?.split('\n').slice(0, 3).join('\n') });
58
86
  }
59
87
 
60
- /**
61
- * Get collected events (for debugging).
62
- * @returns {Array}
63
- */
64
- export function getEvents() {
65
- return [...events];
66
- }
88
+ /* ── Transport ── */
67
89
 
68
- /**
69
- * Clear collected events.
70
- */
71
- export function clear() {
72
- events.length = 0;
90
+ export async function flush() {
91
+ if (!enabled || events.length === 0 || !_backendUrl) return;
92
+ const batch = events.splice(0);
93
+ const headers = { 'Content-Type': 'application/json' };
94
+ if (_token) headers['Authorization'] = `Bearer ${_token}`;
95
+ try {
96
+ const resp = await fetch(`${_backendUrl}/api/telemetry/funnel`, {
97
+ method: 'POST', headers,
98
+ body: JSON.stringify({
99
+ events: batch,
100
+ user_id: _token ? undefined : undefined, // backend derives from token if present
101
+ source: 'cli',
102
+ }),
103
+ });
104
+ if (!resp.ok && resp.status >= 500) events.unshift(...batch);
105
+ } catch { events.unshift(...batch); if (events.length > BUF_LIMIT * 2) events.splice(0, events.length - BUF_LIMIT); }
73
106
  }
74
107
 
75
- /**
76
- * Enable or disable telemetry.
77
- * @param {boolean} value
78
- */
79
- export function setEnabled(value) {
80
- enabled = value;
108
+ export async function shutdown() {
109
+ if (flushTimer) { clearInterval(flushTimer); flushTimer = null; }
110
+ await flush();
81
111
  }
82
112
 
83
- /**
84
- * Get telemetry stats.
85
- */
113
+ /* ── Debug ── */
114
+
115
+ export function getEvents() { return [...events]; }
116
+ export function clear() { events.length = 0; }
117
+ export function setEnabled(value) { enabled = value; }
86
118
  export function getStats() {
87
- const counts = {};
88
- for (const e of events) {
89
- counts[e.event] = (counts[e.event] || 0) + 1;
90
- }
91
- return {
92
- totalEvents: events.length,
93
- enabled,
94
- eventCounts: counts,
95
- };
96
- }
119
+ const counts = {};
120
+ for (const e of events) counts[e.event] = (counts[e.event] || 0) + 1;
121
+ return { totalEvents: events.length, enabled, eventCounts: counts };
122
+ }
@@ -4,6 +4,8 @@
4
4
  * Zero React. Zero Ink. Zero flickering.
5
5
  */
6
6
 
7
+ import * as fs from 'node:fs';
8
+ import * as path from 'node:path';
7
9
  import { startTerminalRepl } from './repl.mjs';
8
10
  import {
9
11
  runSessionsCommand,
@@ -11,6 +13,9 @@ import {
11
13
  runHistoryCommand,
12
14
  } from './analytics.mjs';
13
15
  import { parseArgs } from '../config/cli-args.mjs';
16
+ import * as telemetry from '../telemetry/index.mjs';
17
+ import { bahulamHome } from '../core/paths.mjs';
18
+ import { TarangAuth as Auth } from '../auth/tarang-auth.mjs';
14
19
 
15
20
  // ── Subcommands ──
16
21
 
@@ -83,7 +88,40 @@ function parseKeplerSubcommandArgs(command, argv) {
83
88
  return parsed;
84
89
  }
85
90
 
91
+ function detectInstallFirstRun() {
92
+ const markerPath = path.join(bahulamHome(), '.install_marker');
93
+ try {
94
+ if (fs.existsSync(markerPath)) return false;
95
+ fs.writeFileSync(markerPath, String(Date.now()), { mode: 0o600 });
96
+ return true;
97
+ } catch { return false; }
98
+ }
99
+
86
100
  async function main() {
101
+ // ── Telemetry bootstrap ──
102
+ const _auth = new Auth();
103
+ const _creds = _auth.loadCredentials();
104
+ telemetry.configure(_creds.backendUrl, _creds.token);
105
+
106
+ // Fire install_first_run on first ever CLI invocation
107
+ if (detectInstallFirstRun()) {
108
+ telemetry.track('install_first_run', { version: process.env.npm_package_version || '' });
109
+ }
110
+
111
+ // Check day7_return — fire once per install
112
+ const configDir_ = bahulamHome();
113
+ const installMarker_ = path.join(configDir_, '.install_marker');
114
+ const returnFiredPath_ = path.join(configDir_, '.return_fired');
115
+ try {
116
+ if (fs.existsSync(installMarker_) && !fs.existsSync(returnFiredPath_)) {
117
+ const installTime = parseInt(fs.readFileSync(installMarker_, 'utf-8').trim(), 10);
118
+ if (!isNaN(installTime) && Date.now() - installTime >= 7 * 24 * 60 * 60 * 1000) {
119
+ telemetry.track('day7_return', {});
120
+ fs.writeFileSync(returnFiredPath_, '1', { mode: 0o600 });
121
+ }
122
+ }
123
+ } catch {}
124
+
87
125
  if (subcommand === 'dashboard') {
88
126
  // Launch Bahulam Pulse Next.js dashboard
89
127
  const { spawn } = await import('node:child_process');
@@ -134,7 +172,9 @@ async function main() {
134
172
  const { TarangAuth } = await import('../auth/tarang-auth.mjs');
135
173
  const auth = new TarangAuth();
136
174
  try {
175
+ telemetry.track('login_shown', { method: 'cli_subcommand' });
137
176
  await auth.login();
177
+ telemetry.track('login_completed', { method: 'cli_subcommand' });
138
178
  process.stderr.write('\x1b[32m✓ Login successful!\x1b[0m\n');
139
179
  return;
140
180
  } catch (err) {
@@ -9,6 +9,58 @@
9
9
 
10
10
  import * as path from 'node:path';
11
11
  import { c, stripAnsi, formatElapsed, inPlace } from './ansi.mjs';
12
+ import * as rqueue from '../ui/render-queue.mjs';
13
+
14
+ // Transient one-line status writer that is safe under the render queue.
15
+ // Raw inPlace() writes get REDIRECTED into transcript content when the
16
+ // queue is active (cursor codes stripped) — that leaked one line per
17
+ // spinner frame during resume summarization. queue.status() coalesces
18
+ // and overwrites in place; inPlace stays as the no-queue fallback.
19
+ function transientLine(text) {
20
+ if (rqueue.isActive()) {
21
+ if (text) rqueue.status(text);
22
+ else rqueue.clearStatus();
23
+ return;
24
+ }
25
+ inPlace(text);
26
+ }
27
+
28
+ /**
29
+ * Atomic repaint writer for raw-stdin overlays (resume picker, /model form).
30
+ *
31
+ * While the render queue is active, plain process.stderr.write is REDIRECTED
32
+ * into transcript content with cursor codes stripped — an overlay's
33
+ * "cursor-up N + erase" repaint becomes "append another copy" (the form-
34
+ * replication bug). Each repaint therefore goes through rqueue.raw() as one
35
+ * frame. On the first paint the frame's rows are reserved with newlines so
36
+ * painting near the bottom (input dock) scrolls once up-front and the
37
+ * cursor-relative repaint math stays stable afterwards.
38
+ */
39
+ export function writeOverlayFrame(erasePrev, lines) {
40
+ const body = lines.join('\n') + '\n';
41
+ if (erasePrev > 0) {
42
+ rqueue.raw(`\x1b[${erasePrev}F\r\x1b[J` + body);
43
+ } else {
44
+ rqueue.raw('\n'.repeat(lines.length) + `\x1b[${lines.length}A` + body);
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Erase the previously drawn overlay frame (rows lines tall) so the next
50
+ * prompt / transcript output starts on a clean line. Call from every
51
+ * overlay's cleanup path — without this, picker frames stack on screen
52
+ * when a follow-up prompt (cwd confirm, next overlay) writes below them.
53
+ */
54
+ export function eraseOverlayFrame(rows, summaryLine = '') {
55
+ if (!rows || rows <= 0) {
56
+ if (summaryLine) rqueue.raw(summaryLine + '\n');
57
+ return;
58
+ }
59
+ // Collapse the frame to a compact one-line summary (or nothing). A pure
60
+ // erase leaves a blank void mid-screen because the input dock parks the
61
+ // cursor at the bottom before the next plain write lands.
62
+ rqueue.raw(`\x1b[${rows}F\r\x1b[J` + (summaryLine ? summaryLine + '\n' : ''));
63
+ }
12
64
 
13
65
  // ── One-liners ───────────────────────────────────────────────────────
14
66
 
@@ -182,23 +234,31 @@ export function startResumeProgress(mode = 'full') {
182
234
  if (!active) return;
183
235
  const glyph = frames[frame % frames.length];
184
236
  frame++;
185
- inPlace(` ${c.brand(glyph)} ${c.dim(label)} ${resumeProgressBar(percent)} ${c.dim(formatElapsed(started))}`);
237
+ transientLine(` ${c.brand(glyph)} ${c.dim(label)} ${resumeProgressBar(percent)} ${c.dim(formatElapsed(started))}`);
186
238
  };
187
239
 
188
240
  render();
189
- const timer = setInterval(render, 100);
241
+ let timer = setInterval(render, 100);
190
242
  return {
243
+ // Self-healing: the resume flow stops the progress line to show the
244
+ // cwd-confirm overlay, then keeps reporting phases ('rebuilding local
245
+ // session state', …). A dead update() silently dropped every cue after
246
+ // that prompt — revive the ticker instead (same pattern as
247
+ // updateSpinner in repl-render.mjs).
191
248
  update(nextLabel, nextPercent) {
192
- if (!active) return;
193
249
  if (nextLabel) label = nextLabel;
194
250
  if (Number.isFinite(nextPercent)) percent = Math.max(percent, Math.min(98, nextPercent));
251
+ if (!active) {
252
+ active = true;
253
+ timer = setInterval(render, 100);
254
+ }
195
255
  render();
196
256
  },
197
257
  stop() {
198
258
  if (!active) return;
199
259
  active = false;
200
260
  clearInterval(timer);
201
- inPlace('');
261
+ transientLine('');
202
262
  },
203
263
  };
204
264
  }