@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.
- package/package.json +5 -5
- package/src/auth/tarang-auth.mjs +6 -0
- package/src/config/cli-args.mjs +7 -1
- package/src/config/model-catalog.mjs +57 -0
- package/src/core/approval-log.mjs +23 -0
- package/src/core/approval.mjs +93 -17
- package/src/core/bundled-runtime.mjs +12 -0
- package/src/core/error-guidance.mjs +8 -4
- package/src/core/file-diff.mjs +1 -1
- package/src/core/local-agent.mjs +3 -3
- package/src/core/risk-tier.mjs +53 -2
- package/src/core/safety.mjs +61 -4
- package/src/core/tool-executor.mjs +25 -13
- package/src/core/trust.mjs +5 -3
- package/src/index.mjs +7 -3
- package/src/permissions/command-classifier.mjs +50 -2
- package/src/telemetry/index.mjs +97 -71
- package/src/terminal/main.mjs +40 -0
- package/src/terminal/repl-format.mjs +64 -4
- package/src/terminal/repl-model-form.mjs +132 -0
- package/src/terminal/repl-render.mjs +256 -33
- package/src/terminal/repl-resume.mjs +27 -12
- package/src/terminal/repl-state.mjs +16 -0
- package/src/terminal/repl.mjs +644 -85
- package/src/terminal/tool-display.mjs +20 -1
- package/src/ui/approval.mjs +33 -5
- package/src/ui/input-dock.mjs +192 -16
- package/src/ui/render-queue.mjs +500 -0
- package/src/ui/slash-commands.mjs +2 -0
- package/src/ui/sub-agent.mjs +17 -2
- package/src/ui/tool-card.mjs +156 -13
- package/src/ui/tool-details.mjs +96 -3
package/src/core/safety.mjs
CHANGED
|
@@ -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
|
-
* @
|
|
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 (
|
|
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 =
|
|
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(
|
|
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) {
|
package/src/core/trust.mjs
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
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}
|
|
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:
|
|
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
|
-
|
|
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)) {
|
package/src/telemetry/index.mjs
CHANGED
|
@@ -1,96 +1,122 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Telemetry
|
|
2
|
+
* Telemetry — Funnel event emitter (PRD-076 W3).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
};
|
|
28
|
+
export function disable() {
|
|
29
|
+
enabled = false;
|
|
30
|
+
if (flushTimer) { clearInterval(flushTimer); flushTimer = null; }
|
|
31
|
+
}
|
|
25
32
|
|
|
26
|
-
|
|
33
|
+
export function configure(backendUrl, token) {
|
|
34
|
+
_backendUrl = backendUrl;
|
|
35
|
+
_token = token;
|
|
36
|
+
}
|
|
27
37
|
|
|
28
|
-
|
|
29
|
-
if (events.length > 1000) {
|
|
30
|
-
events.splice(0, events.length - 1000);
|
|
31
|
-
}
|
|
38
|
+
/* ── Device ID ── */
|
|
32
39
|
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
+
}
|
package/src/terminal/main.mjs
CHANGED
|
@@ -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
|
-
|
|
237
|
+
transientLine(` ${c.brand(glyph)} ${c.dim(label)} ${resumeProgressBar(percent)} ${c.dim(formatElapsed(started))}`);
|
|
186
238
|
};
|
|
187
239
|
|
|
188
240
|
render();
|
|
189
|
-
|
|
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
|
-
|
|
261
|
+
transientLine('');
|
|
202
262
|
},
|
|
203
263
|
};
|
|
204
264
|
}
|