agy-powerline 1.0.1 → 1.1.0
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/CHANGELOG.md +18 -0
- package/README.md +4 -1
- package/package.json +1 -1
- package/src/cli/init.js +2 -0
- package/src/lib/catalog.js +18 -0
- package/src/lib/config.js +11 -3
- package/src/lib/exec.js +8 -2
- package/src/lib/fit.js +3 -0
- package/src/lib/formatters.js +34 -0
- package/src/lib/layouts/lines.js +29 -0
- package/src/lib/layouts/pills.js +28 -0
- package/src/lib/log.js +2 -2
- package/src/lib/mock-data.js +7 -0
- package/src/lib/paths.js +55 -8
- package/src/lib/payload-dump.js +22 -4
- package/src/lib/session.js +10 -0
- package/src/lib/widget-style.js +7 -0
- package/src/runtime/statusline.js +80 -46
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.1.0] - 2026-10-12
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `tasks` widget: running subagents and background tasks, e.g. `👥 2 subagents, 1 task`, hidden when nothing runs; part of the `full` preset (#14)
|
|
13
|
+
- `mode` widget: the approval mode (e.g. `accept-edits`) and the Vim mode, which Antigravity's own status line shows; hidden in the default mode with Vim editing off; part of the `workspace` and `full` presets (#15)
|
|
14
|
+
- `title` widget: the conversation's title on one line, cut to 30 columns, hidden until the conversation has one (#16)
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- The statusline no longer hangs until Antigravity kills it when Antigravity keeps its input open without sending anything; repeated kills made Antigravity turn the statusline off (#13)
|
|
19
|
+
|
|
20
|
+
## [1.0.2] - 2026-10-11
|
|
21
|
+
|
|
22
|
+
### Security
|
|
23
|
+
|
|
24
|
+
- On macOS and Linux, the config, `exec` cache, problem log and payload dump are now readable by your account only, and saving Antigravity's `settings.json` keeps its permissions. Run `agy-powerline init --yes` after updating to apply this to existing files (#11)
|
|
25
|
+
|
|
8
26
|
## [1.0.1] - 2026-10-11
|
|
9
27
|
|
|
10
28
|
### Fixed
|
package/README.md
CHANGED
|
@@ -42,7 +42,7 @@ Start from a preset, then adjust:
|
|
|
42
42
|
| ----------- | ---------------------------------------------------------------------------- |
|
|
43
43
|
| `default` | Model, context, git; 5h and 7d quotas with resets on a second row |
|
|
44
44
|
| `compact` | One line: short model, tightest quota, context %, git |
|
|
45
|
-
| `workspace` | Project, git, agent state, model; context
|
|
45
|
+
| `workspace` | Project, git, agent state, model; context, tightest quota and mode |
|
|
46
46
|
| `limits` | Every quota (5h, 7d, third-party), one per row |
|
|
47
47
|
| `full` | Everything: session, project and git; context, tokens and cache; both quotas |
|
|
48
48
|
|
|
@@ -86,6 +86,9 @@ Preview them all with `npx agy-powerline theme --gallery`.
|
|
|
86
86
|
| `git` | Branch, uncommitted changes and ahead/behind |
|
|
87
87
|
| `project`, `cwd` | Project folder, current folder |
|
|
88
88
|
| `agentState`, `plan`, `sandbox`, `artifacts`, `version` | What the agent is doing, your plan, sandbox, artifacts, CLI version |
|
|
89
|
+
| `tasks` | Running subagents and background tasks |
|
|
90
|
+
| `mode` | Approval mode (e.g. accept-edits) and Vim mode |
|
|
91
|
+
| `title` | The conversation's title |
|
|
89
92
|
| `totalTokens`, `cacheHit`, `cacheRead`, `cacheCreate` | Session tokens and prompt cache usage |
|
|
90
93
|
| `ram` | Memory used on your machine |
|
|
91
94
|
| `customText`, `exec` | Your own text, or the output of a [shell command](docs/configuration.md#shell-command-widget-exec) |
|
package/package.json
CHANGED
package/src/cli/init.js
CHANGED
|
@@ -11,6 +11,7 @@ const {
|
|
|
11
11
|
readSettingsForUpdate,
|
|
12
12
|
settingsBackupPath,
|
|
13
13
|
writeJsonSafely,
|
|
14
|
+
restrictPrivateFiles,
|
|
14
15
|
isWindowsNodeOnWsl,
|
|
15
16
|
windowsNodeOnWslMessage,
|
|
16
17
|
cmdBreakingChars,
|
|
@@ -81,6 +82,7 @@ async function initCommand(options = {}) {
|
|
|
81
82
|
|
|
82
83
|
try {
|
|
83
84
|
fs.mkdirSync(installDir, { recursive: true });
|
|
85
|
+
restrictPrivateFiles(installDir);
|
|
84
86
|
|
|
85
87
|
const destStatusline = path.join(installDir, "statusline.js");
|
|
86
88
|
const destConfig = path.join(installDir, "statusline.config.json");
|
package/src/lib/catalog.js
CHANGED
|
@@ -105,6 +105,24 @@ const WIDGET_CATALOG = [
|
|
|
105
105
|
unique: true,
|
|
106
106
|
description: "number of artifacts",
|
|
107
107
|
},
|
|
108
|
+
{
|
|
109
|
+
id: "tasks",
|
|
110
|
+
label: "Subagents & Tasks",
|
|
111
|
+
unique: true,
|
|
112
|
+
description: "running subagents and background tasks, shown only when there are some",
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
id: "mode",
|
|
116
|
+
label: "Mode",
|
|
117
|
+
unique: true,
|
|
118
|
+
description: "approval mode (e.g. accept-edits) and Vim mode, shown only when one is on",
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: "title",
|
|
122
|
+
label: "Conversation Title",
|
|
123
|
+
unique: true,
|
|
124
|
+
description: "name of the current conversation, shown once it has one",
|
|
125
|
+
},
|
|
108
126
|
{ id: "ram", label: "RAM Usage", unique: true, description: "memory used on this machine" },
|
|
109
127
|
{ id: "version", label: "CLI Version", unique: true, description: "Antigravity CLI version" },
|
|
110
128
|
{
|
package/src/lib/config.js
CHANGED
|
@@ -6,7 +6,7 @@ const { resolveColorModeId } = require("./colors");
|
|
|
6
6
|
const { resolveThemeId } = require("./themes");
|
|
7
7
|
const { stripControl, normalizeClock } = require("./formatters");
|
|
8
8
|
const { normalizePowerline } = require("./powerline");
|
|
9
|
-
const { writeFileAtomic } = require("./paths");
|
|
9
|
+
const { writeFileAtomic, PRIVATE_FILE_MODE } = require("./paths");
|
|
10
10
|
|
|
11
11
|
// Display text in widget configs is printed as-is, so strip terminal control
|
|
12
12
|
// from it. cmd/command are left alone: they are executed, not printed.
|
|
@@ -40,6 +40,9 @@ const VALID_WIDGET_IDS = new Set([
|
|
|
40
40
|
"plan",
|
|
41
41
|
"agentState",
|
|
42
42
|
"artifacts",
|
|
43
|
+
"tasks",
|
|
44
|
+
"mode",
|
|
45
|
+
"title",
|
|
43
46
|
"ram",
|
|
44
47
|
"version",
|
|
45
48
|
"sandbox",
|
|
@@ -225,7 +228,9 @@ function saveConfig(config) {
|
|
|
225
228
|
throw new InvalidConfigError(getConfigPath(), current.error);
|
|
226
229
|
}
|
|
227
230
|
|
|
228
|
-
writeFileAtomic(getConfigPath(), JSON.stringify(mergeConfig(config), null, 2) + "\n"
|
|
231
|
+
writeFileAtomic(getConfigPath(), JSON.stringify(mergeConfig(config), null, 2) + "\n", {
|
|
232
|
+
mode: PRIVATE_FILE_MODE,
|
|
233
|
+
});
|
|
229
234
|
}
|
|
230
235
|
|
|
231
236
|
// Presets set the widget list only; the layout (Pills or Lines) stays the one
|
|
@@ -258,7 +263,7 @@ const PRESETS = {
|
|
|
258
263
|
workspace: {
|
|
259
264
|
id: "workspace",
|
|
260
265
|
name: "Several repos",
|
|
261
|
-
description: "Project, git, agent state and model; context
|
|
266
|
+
description: "Project, git, agent state and model; context, tightest quota and mode below",
|
|
262
267
|
widgets: [
|
|
263
268
|
{ id: "project" },
|
|
264
269
|
{ id: "git" },
|
|
@@ -269,6 +274,7 @@ const PRESETS = {
|
|
|
269
274
|
{ id: "context" },
|
|
270
275
|
{ id: "separator" },
|
|
271
276
|
{ id: "quota" },
|
|
277
|
+
{ id: "mode" },
|
|
272
278
|
],
|
|
273
279
|
},
|
|
274
280
|
limits: {
|
|
@@ -292,6 +298,7 @@ const PRESETS = {
|
|
|
292
298
|
description: "Session, project and git; context, tokens and cache; both quotas, on three rows",
|
|
293
299
|
widgets: [
|
|
294
300
|
{ id: "agentState" },
|
|
301
|
+
{ id: "tasks" },
|
|
295
302
|
{ id: "model" },
|
|
296
303
|
{ id: "plan" },
|
|
297
304
|
{ id: "sandbox" },
|
|
@@ -302,6 +309,7 @@ const PRESETS = {
|
|
|
302
309
|
{ id: "context" },
|
|
303
310
|
{ id: "totalTokens", split: true },
|
|
304
311
|
{ id: "cacheHit" },
|
|
312
|
+
{ id: "mode" },
|
|
305
313
|
{ id: "lineBreak" },
|
|
306
314
|
{ id: "fiveHourBar" },
|
|
307
315
|
{ id: "fiveHourReset" },
|
package/src/lib/exec.js
CHANGED
|
@@ -7,6 +7,7 @@ const crypto = require("crypto");
|
|
|
7
7
|
const { execSync } = require("child_process");
|
|
8
8
|
const { stripControl } = require("./formatters");
|
|
9
9
|
const { logWarning } = require("./log");
|
|
10
|
+
const { PRIVATE_FILE_MODE } = require("./paths");
|
|
10
11
|
|
|
11
12
|
const DEFAULT_TIMEOUT_MS = 500;
|
|
12
13
|
const DEFAULT_INTERVAL_SEC = 10;
|
|
@@ -54,7 +55,8 @@ function loadDiskCache() {
|
|
|
54
55
|
|
|
55
56
|
// Several agy windows refresh at once, so the cache is written to a fresh temp
|
|
56
57
|
// file and renamed into place: a reader never sees half a file. "wx" refuses a
|
|
57
|
-
// file that already exists (or a planted link) in a shared temp folder
|
|
58
|
+
// file that already exists (or a planted link) in a shared temp folder, and the
|
|
59
|
+
// file is owner-only because it holds the output of the user's commands.
|
|
58
60
|
function saveDiskCache(cache) {
|
|
59
61
|
let tmp = "";
|
|
60
62
|
try {
|
|
@@ -64,7 +66,11 @@ function saveDiskCache(cache) {
|
|
|
64
66
|
fs.mkdirSync(dir, { recursive: true });
|
|
65
67
|
}
|
|
66
68
|
tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
|
|
67
|
-
fs.writeFileSync(tmp, JSON.stringify(cache), {
|
|
69
|
+
fs.writeFileSync(tmp, JSON.stringify(cache), {
|
|
70
|
+
encoding: "utf8",
|
|
71
|
+
flag: "wx",
|
|
72
|
+
mode: PRIVATE_FILE_MODE,
|
|
73
|
+
});
|
|
68
74
|
fs.renameSync(tmp, file);
|
|
69
75
|
} catch {
|
|
70
76
|
// The cache is best effort: skip this write rather than risk a torn file.
|
package/src/lib/fit.js
CHANGED
|
@@ -6,18 +6,21 @@ const { charWidth, visibleWidth } = require("./formatters");
|
|
|
6
6
|
// first entry first. Ids not listed (custom ones) go before all of them.
|
|
7
7
|
const DROP_ORDER = [
|
|
8
8
|
"customText",
|
|
9
|
+
"title",
|
|
9
10
|
"version",
|
|
10
11
|
"cacheCreate",
|
|
11
12
|
"cacheRead",
|
|
12
13
|
"totalTokens",
|
|
13
14
|
"cacheHit",
|
|
14
15
|
"artifacts",
|
|
16
|
+
"tasks",
|
|
15
17
|
"plan",
|
|
16
18
|
"ram",
|
|
17
19
|
"project",
|
|
18
20
|
"cwd",
|
|
19
21
|
"exec",
|
|
20
22
|
"agentState",
|
|
23
|
+
"mode",
|
|
21
24
|
"thirdPartyWeekly",
|
|
22
25
|
"thirdPartyFiveHour",
|
|
23
26
|
"weeklyReset",
|
package/src/lib/formatters.js
CHANGED
|
@@ -477,6 +477,37 @@ function formatGitSync(ahead, behind, ascii = false) {
|
|
|
477
477
|
return "";
|
|
478
478
|
}
|
|
479
479
|
|
|
480
|
+
// "2 subagents, 1 task", or "" when nothing runs.
|
|
481
|
+
function formatTasks(subagents, tasks) {
|
|
482
|
+
const part = (n, word) => (n > 0 ? `${n} ${word}${n === 1 ? "" : "s"}` : "");
|
|
483
|
+
return [part(subagents, "subagent"), part(tasks, "task")].filter(Boolean).join(", ");
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// "accept-edits, NORMAL", or "" in the default mode with Vim editing off.
|
|
487
|
+
function formatMode(approval, vim) {
|
|
488
|
+
return [approval === "default" ? "" : approval, vim].filter(Boolean).join(", ");
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Cuts text to `max` terminal columns, ending with an ellipsis when cut.
|
|
492
|
+
function clipText(text, max, ascii = false) {
|
|
493
|
+
const value = String(text);
|
|
494
|
+
if (visibleWidth(value) <= max) {
|
|
495
|
+
return value;
|
|
496
|
+
}
|
|
497
|
+
const ellipsis = ascii ? "..." : "…";
|
|
498
|
+
let out = "";
|
|
499
|
+
let width = 0;
|
|
500
|
+
for (const char of value) {
|
|
501
|
+
const w = charWidth(char.codePointAt(0));
|
|
502
|
+
if (width + w > max - ellipsis.length) {
|
|
503
|
+
break;
|
|
504
|
+
}
|
|
505
|
+
out += char;
|
|
506
|
+
width += w;
|
|
507
|
+
}
|
|
508
|
+
return out.trimEnd() + ellipsis;
|
|
509
|
+
}
|
|
510
|
+
|
|
480
511
|
// Removes escape sequences and control characters from text that did not
|
|
481
512
|
// come from us (payload fields, branch names, command output), so it cannot
|
|
482
513
|
// drive the terminal: cursor moves, OSC 52 clipboard writes, hyperlinks...
|
|
@@ -555,6 +586,9 @@ module.exports = {
|
|
|
555
586
|
shortModelName,
|
|
556
587
|
formatTokenSplit,
|
|
557
588
|
formatGitSync,
|
|
589
|
+
formatTasks,
|
|
590
|
+
formatMode,
|
|
591
|
+
clipText,
|
|
558
592
|
quotaColor,
|
|
559
593
|
contextColor,
|
|
560
594
|
resolveQuotaThresholds,
|
package/src/lib/layouts/lines.js
CHANGED
|
@@ -8,6 +8,9 @@ const {
|
|
|
8
8
|
paceIndex,
|
|
9
9
|
paceGlyph,
|
|
10
10
|
formatGitSync,
|
|
11
|
+
formatTasks,
|
|
12
|
+
formatMode,
|
|
13
|
+
clipText,
|
|
11
14
|
formatTokens,
|
|
12
15
|
formatContextTokens,
|
|
13
16
|
shortModelName,
|
|
@@ -28,6 +31,7 @@ const {
|
|
|
28
31
|
hasNoData,
|
|
29
32
|
RAM_CAUTION,
|
|
30
33
|
RAM_DANGER,
|
|
34
|
+
TITLE_MAX,
|
|
31
35
|
} = require("../widget-style");
|
|
32
36
|
|
|
33
37
|
const LINES = NAMED_COLORS;
|
|
@@ -374,6 +378,31 @@ function buildLinesSegment(id, session, palette, emojis, ascii = false, widget =
|
|
|
374
378
|
}
|
|
375
379
|
return fg(palette.text) + tag() + String(session.artifacts) + RESET;
|
|
376
380
|
|
|
381
|
+
case "tasks": {
|
|
382
|
+
const text = formatTasks(session.subagents, session.tasks);
|
|
383
|
+
if (!text) {
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
return fg(palette.text) + tag() + text + RESET;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
case "mode": {
|
|
390
|
+
const text = formatMode(session.approvalMode, session.vimMode);
|
|
391
|
+
if (!text) {
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
// An approval mode like accept-edits changes what the agent may do alone.
|
|
395
|
+
const approval = session.approvalMode && session.approvalMode !== "default";
|
|
396
|
+
const color = approval ? palette.quotaHigh || palette.yellow || LINES.yellow : palette.text;
|
|
397
|
+
return fg(color) + tag() + text + RESET;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
case "title":
|
|
401
|
+
if (!session.title) {
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
return fg(palette.text) + tag() + clipText(session.title, TITLE_MAX, ascii) + RESET;
|
|
405
|
+
|
|
377
406
|
case "sandbox": {
|
|
378
407
|
if (!session.sandbox) {
|
|
379
408
|
return null;
|
package/src/lib/layouts/pills.js
CHANGED
|
@@ -24,6 +24,9 @@ const {
|
|
|
24
24
|
shortModelName,
|
|
25
25
|
formatTokenSplit,
|
|
26
26
|
formatGitSync,
|
|
27
|
+
formatTasks,
|
|
28
|
+
formatMode,
|
|
29
|
+
clipText,
|
|
27
30
|
homePath,
|
|
28
31
|
basenameOf,
|
|
29
32
|
label,
|
|
@@ -41,6 +44,7 @@ const {
|
|
|
41
44
|
hasNoData,
|
|
42
45
|
RAM_CAUTION,
|
|
43
46
|
RAM_DANGER,
|
|
47
|
+
TITLE_MAX,
|
|
44
48
|
} = require("../widget-style");
|
|
45
49
|
|
|
46
50
|
const WHITE = [255, 255, 255];
|
|
@@ -420,6 +424,30 @@ function buildSegment(id, session, colors, emojis, ascii = false, widget = {}, c
|
|
|
420
424
|
}
|
|
421
425
|
return pill(colors.meta, lbl(String(session.artifacts)));
|
|
422
426
|
|
|
427
|
+
case "tasks": {
|
|
428
|
+
const text = formatTasks(session.subagents, session.tasks);
|
|
429
|
+
if (!text) {
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
return pill(colors.meta, lbl(text));
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
case "mode": {
|
|
436
|
+
const text = formatMode(session.approvalMode, session.vimMode);
|
|
437
|
+
if (!text) {
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
// An approval mode like accept-edits changes what the agent may do alone.
|
|
441
|
+
const approval = session.approvalMode && session.approvalMode !== "default";
|
|
442
|
+
return pill(approval ? colors.caution : colors.meta, lbl(text));
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
case "title":
|
|
446
|
+
if (!session.title) {
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
return pill(colors.meta, lbl(clipText(session.title, TITLE_MAX, ascii)));
|
|
450
|
+
|
|
423
451
|
case "ram": {
|
|
424
452
|
const pct = session.ramPct ?? 0;
|
|
425
453
|
let color = colors.meta;
|
package/src/lib/log.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
const fs = require("fs");
|
|
10
10
|
const os = require("os");
|
|
11
11
|
const path = require("path");
|
|
12
|
-
const { writeFileAtomic, readInstallMeta } = require("./paths");
|
|
12
|
+
const { writeFileAtomic, readInstallMeta, PRIVATE_FILE_MODE } = require("./paths");
|
|
13
13
|
const { stripControl } = require("./formatters");
|
|
14
14
|
|
|
15
15
|
const LOG_NAME = "agy-powerline.log";
|
|
@@ -246,7 +246,7 @@ function logEvent(level, code, details = {}) {
|
|
|
246
246
|
lines = lines.slice(1);
|
|
247
247
|
}
|
|
248
248
|
|
|
249
|
-
writeFileAtomic(file, lines.join("\n") + "\n");
|
|
249
|
+
writeFileAtomic(file, lines.join("\n") + "\n", { mode: PRIVATE_FILE_MODE });
|
|
250
250
|
} catch {
|
|
251
251
|
// Read-only disk, full disk, permissions: the bar matters more.
|
|
252
252
|
}
|
package/src/lib/mock-data.js
CHANGED
|
@@ -62,6 +62,13 @@ function sampleData() {
|
|
|
62
62
|
},
|
|
63
63
|
sandbox: { enabled: false },
|
|
64
64
|
artifact_count: 2,
|
|
65
|
+
subagents: [
|
|
66
|
+
{ name: "research", role: "Find where the config is loaded", status: "running" },
|
|
67
|
+
{ name: "review", role: "Check the error messages", status: "completed" },
|
|
68
|
+
],
|
|
69
|
+
task_count: 1,
|
|
70
|
+
cycle_mode: "accept-edits",
|
|
71
|
+
conversation_title: "Fix the config loader",
|
|
65
72
|
plan_tier: "Pro",
|
|
66
73
|
terminal_width: process.stdout.columns || 120,
|
|
67
74
|
};
|
package/src/lib/paths.js
CHANGED
|
@@ -28,17 +28,25 @@ const RUNTIME_FILES = [
|
|
|
28
28
|
path.join("layouts", "pills.js"),
|
|
29
29
|
];
|
|
30
30
|
|
|
31
|
+
// Files that can hold the user's commands, their output or session details.
|
|
32
|
+
// They are written readable by the owner only, so other accounts on a shared
|
|
33
|
+
// machine can't read them (Windows profile folders are private already).
|
|
34
|
+
const PRIVATE_FILES = [
|
|
35
|
+
"statusline.config.json",
|
|
36
|
+
".exec-cache.json",
|
|
37
|
+
"last-payload.json",
|
|
38
|
+
// LOG_NAME in log.js, spelled out because log.js requires this file.
|
|
39
|
+
"agy-powerline.log",
|
|
40
|
+
];
|
|
41
|
+
const PRIVATE_FILE_MODE = 0o600;
|
|
42
|
+
|
|
31
43
|
// Everything init or the runtime may create in the install dir. uninstall
|
|
32
44
|
// --delete removes only these, never the whole directory tree.
|
|
33
45
|
const INSTALLED_FILES = [
|
|
34
46
|
"statusline.js",
|
|
35
47
|
"statusline.cmd",
|
|
36
48
|
INSTALL_META_NAME,
|
|
37
|
-
|
|
38
|
-
".exec-cache.json",
|
|
39
|
-
"last-payload.json",
|
|
40
|
-
// LOG_NAME in log.js, spelled out because log.js requires this file.
|
|
41
|
-
"agy-powerline.log",
|
|
49
|
+
...PRIVATE_FILES,
|
|
42
50
|
...RUNTIME_FILES,
|
|
43
51
|
];
|
|
44
52
|
|
|
@@ -238,11 +246,24 @@ function writeJsonSafely(file, data) {
|
|
|
238
246
|
}
|
|
239
247
|
|
|
240
248
|
// Writes via temp file + rename so an interrupted write cannot leave a
|
|
241
|
-
// truncated file behind.
|
|
242
|
-
|
|
249
|
+
// truncated file behind. The new file gets `mode` when given, otherwise the
|
|
250
|
+
// mode the existing file had, so rewriting a file never widens who can read
|
|
251
|
+
// it (the rename would otherwise reset it to the default).
|
|
252
|
+
function writeFileAtomic(file, body, { mode } = {}) {
|
|
243
253
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
244
254
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
245
|
-
|
|
255
|
+
const target = process.platform === "win32" ? undefined : (mode ?? existingMode(file));
|
|
256
|
+
fs.writeFileSync(tmp, body, { encoding: "utf8", mode: target ?? 0o666 });
|
|
257
|
+
if (target !== undefined) {
|
|
258
|
+
// The mode passed to writeFileSync is reduced by the umask; set it exactly.
|
|
259
|
+
// Some filesystems (e.g. Windows drives under WSL) refuse chmod; the write
|
|
260
|
+
// still matters more.
|
|
261
|
+
try {
|
|
262
|
+
fs.chmodSync(tmp, target);
|
|
263
|
+
} catch {
|
|
264
|
+
// Keep the mode writeFileSync gave it.
|
|
265
|
+
}
|
|
266
|
+
}
|
|
246
267
|
|
|
247
268
|
try {
|
|
248
269
|
fs.renameSync(tmp, file);
|
|
@@ -253,6 +274,29 @@ function writeFileAtomic(file, body) {
|
|
|
253
274
|
}
|
|
254
275
|
}
|
|
255
276
|
|
|
277
|
+
function existingMode(file) {
|
|
278
|
+
try {
|
|
279
|
+
return fs.statSync(file).mode & 0o777;
|
|
280
|
+
} catch {
|
|
281
|
+
return undefined;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Installs made before the private-file change left these files readable by
|
|
286
|
+
// everyone; init narrows them. Best effort: a file we can't chmod stays as is.
|
|
287
|
+
function restrictPrivateFiles(installDir) {
|
|
288
|
+
if (process.platform === "win32") {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
for (const name of PRIVATE_FILES) {
|
|
292
|
+
try {
|
|
293
|
+
fs.chmodSync(path.join(installDir, name), PRIVATE_FILE_MODE);
|
|
294
|
+
} catch {
|
|
295
|
+
// Missing file, or not ours to change.
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
256
300
|
function removeSettingsFile(file = AGY_SETTINGS) {
|
|
257
301
|
if (fs.existsSync(file)) {
|
|
258
302
|
fs.copyFileSync(file, settingsBackupPath(file));
|
|
@@ -305,6 +349,9 @@ module.exports = {
|
|
|
305
349
|
INSTALL_META_NAME,
|
|
306
350
|
RUNTIME_FILES,
|
|
307
351
|
INSTALLED_FILES,
|
|
352
|
+
PRIVATE_FILES,
|
|
353
|
+
PRIVATE_FILE_MODE,
|
|
354
|
+
restrictPrivateFiles,
|
|
308
355
|
resolveInstallDir,
|
|
309
356
|
isTrustedInstallDir,
|
|
310
357
|
statuslineCommand,
|
package/src/lib/payload-dump.js
CHANGED
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
// branch names become placeholders. A field Antigravity adds later is left
|
|
8
8
|
// out until it is listed here.
|
|
9
9
|
|
|
10
|
-
const fs = require("fs");
|
|
11
10
|
const path = require("path");
|
|
12
11
|
const { stripControl } = require("./formatters");
|
|
12
|
+
const { writeFileAtomic, PRIVATE_FILE_MODE } = require("./paths");
|
|
13
13
|
|
|
14
14
|
const NUMBER = "number";
|
|
15
15
|
const BOOL = "boolean";
|
|
@@ -17,6 +17,7 @@ const TEXT = "text";
|
|
|
17
17
|
const TIME = "time";
|
|
18
18
|
const PATH = "path";
|
|
19
19
|
const BRANCH = "branch";
|
|
20
|
+
const TITLE = "title";
|
|
20
21
|
|
|
21
22
|
const QUOTA = {
|
|
22
23
|
remaining_fraction: NUMBER,
|
|
@@ -52,6 +53,11 @@ const SCHEMA = {
|
|
|
52
53
|
vcs: VCS,
|
|
53
54
|
sandbox: { enabled: BOOL, allow_network: BOOL },
|
|
54
55
|
artifact_count: NUMBER,
|
|
56
|
+
subagents: "subagents",
|
|
57
|
+
task_count: NUMBER,
|
|
58
|
+
cycle_mode: TEXT,
|
|
59
|
+
vim: { mode: TEXT },
|
|
60
|
+
conversation_title: TITLE,
|
|
55
61
|
terminal_width: NUMBER,
|
|
56
62
|
};
|
|
57
63
|
|
|
@@ -102,6 +108,18 @@ function pick(value, rule, seen) {
|
|
|
102
108
|
if (rule === BRANCH) {
|
|
103
109
|
return typeof value === "string" && value ? branchPlaceholder(value) : undefined;
|
|
104
110
|
}
|
|
111
|
+
if (rule === TITLE) {
|
|
112
|
+
// A title can name the user's work; the placeholder keeps its length.
|
|
113
|
+
return typeof value === "string" && value
|
|
114
|
+
? "title" + "x".repeat(Math.max(0, Math.min(200, value.length) - 5))
|
|
115
|
+
: undefined;
|
|
116
|
+
}
|
|
117
|
+
if (rule === "subagents") {
|
|
118
|
+
// Only the status: a subagent's name and role describe the user's work.
|
|
119
|
+
return Array.isArray(value)
|
|
120
|
+
? value.slice(0, 50).map((agent) => pick(agent, { status: TEXT }, seen) || {})
|
|
121
|
+
: undefined;
|
|
122
|
+
}
|
|
105
123
|
if (rule === "quota") {
|
|
106
124
|
if (typeof value !== "object") {
|
|
107
125
|
return undefined;
|
|
@@ -160,9 +178,9 @@ function writePayloadDump(target, rawInput) {
|
|
|
160
178
|
}
|
|
161
179
|
|
|
162
180
|
try {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
181
|
+
writeFileAtomic(path.resolve(target), JSON.stringify(body, null, 2) + "\n", {
|
|
182
|
+
mode: PRIVATE_FILE_MODE,
|
|
183
|
+
});
|
|
166
184
|
} catch {
|
|
167
185
|
// Best-effort debug write; the bar matters more.
|
|
168
186
|
}
|
package/src/lib/session.js
CHANGED
|
@@ -175,6 +175,16 @@ function extractSession(data, config) {
|
|
|
175
175
|
plan: firstText(data.plan_tier),
|
|
176
176
|
agentState: firstText(data.agent_state),
|
|
177
177
|
artifacts: Math.floor(count(data.artifact_count)),
|
|
178
|
+
// Finished subagents stay in the list Antigravity sends; only running ones count.
|
|
179
|
+
subagents: Array.isArray(data.subagents)
|
|
180
|
+
? data.subagents.filter((agent) => agent?.status === "running").length
|
|
181
|
+
: 0,
|
|
182
|
+
tasks: Math.floor(count(data.task_count)),
|
|
183
|
+
// Left out in the default mode, and with Vim editing off.
|
|
184
|
+
approvalMode: firstText(data.cycle_mode),
|
|
185
|
+
vimMode: firstText(data.vim?.mode),
|
|
186
|
+
// Left out until the conversation has a title; kept to one line.
|
|
187
|
+
title: firstText(data.conversation_title).replace(/\s+/g, " ").trim(),
|
|
178
188
|
version: firstText(data.version),
|
|
179
189
|
sandbox: data.sandbox?.enabled === true,
|
|
180
190
|
sandboxNet: data.sandbox?.allow_network === true,
|
package/src/lib/widget-style.js
CHANGED
|
@@ -102,6 +102,9 @@ const WIDGET_TAGS = {
|
|
|
102
102
|
plan: { icon: "⭐", name: "Plan" },
|
|
103
103
|
agentState: { icon: "🟣", name: "Agent" },
|
|
104
104
|
artifacts: { icon: "📎", name: "Artifacts" },
|
|
105
|
+
tasks: { icon: "👥", name: "" },
|
|
106
|
+
mode: { icon: "🧭", name: "" },
|
|
107
|
+
title: { icon: "💬", name: "" },
|
|
105
108
|
ram: { icon: "💻", name: "RAM" },
|
|
106
109
|
version: { icon: "🔖", name: "" },
|
|
107
110
|
sandbox: { icon: "🔒", name: "" },
|
|
@@ -127,6 +130,9 @@ function widgetTag(id, { emojis = true, raw = false, icon } = {}) {
|
|
|
127
130
|
return `${icon ?? tag.icon} ${tag.iconOnly ? "" : named}`;
|
|
128
131
|
}
|
|
129
132
|
|
|
133
|
+
// Longest conversation title shown, in terminal columns.
|
|
134
|
+
const TITLE_MAX = 30;
|
|
135
|
+
|
|
130
136
|
// RAM use (%) that turns the ram widget to the caution, then danger color.
|
|
131
137
|
const RAM_CAUTION = 80;
|
|
132
138
|
const RAM_DANGER = 90;
|
|
@@ -154,4 +160,5 @@ module.exports = {
|
|
|
154
160
|
hasNoData,
|
|
155
161
|
RAM_CAUTION,
|
|
156
162
|
RAM_DANGER,
|
|
163
|
+
TITLE_MAX,
|
|
157
164
|
};
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
const fs = require("fs");
|
|
3
2
|
const path = require("path");
|
|
4
3
|
const { readConfigFile, renderStatusline } = require("./shared");
|
|
5
4
|
const { isTrustedInstallDir } = require("./paths");
|
|
@@ -17,6 +16,12 @@ const {
|
|
|
17
16
|
// A refresh slower than this is worth a line in the problem log.
|
|
18
17
|
const SLOW_RENDER_MS = 750;
|
|
19
18
|
|
|
19
|
+
// Antigravity can start the command and keep stdin open without writing to it
|
|
20
|
+
// (google-antigravity/antigravity-cli#698). A blocking read would wait until
|
|
21
|
+
// Antigravity kills the script, and a few kills in a row turn the custom
|
|
22
|
+
// statusline off, so the read gives up after this long.
|
|
23
|
+
const STDIN_TIMEOUT_MS = 1000;
|
|
24
|
+
|
|
20
25
|
// The config decides whether problems are logged, so it is read first. A
|
|
21
26
|
// broken config can't say, so logging stays on (AGY_POWERLINE_LOG=0 still
|
|
22
27
|
// turns it off).
|
|
@@ -32,58 +37,87 @@ process.on("uncaughtException", (err) => {
|
|
|
32
37
|
process.exit(0);
|
|
33
38
|
});
|
|
34
39
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
// Resolves with what stdin sent and whether it closed before the deadline.
|
|
41
|
+
function readStdin(timeoutMs) {
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
const chunks = [];
|
|
44
|
+
let done = false;
|
|
45
|
+
const finish = (complete) => {
|
|
46
|
+
if (done) return;
|
|
47
|
+
done = true;
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
// An open stdin would keep the process alive after the bar is printed.
|
|
50
|
+
process.stdin.destroy();
|
|
51
|
+
resolve({ text: Buffer.concat(chunks).toString("utf8"), complete });
|
|
52
|
+
};
|
|
53
|
+
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
54
|
+
process.stdin.on("data", (chunk) => chunks.push(chunk));
|
|
55
|
+
process.stdin.on("end", () => finish(true));
|
|
56
|
+
process.stdin.on("error", () => finish(true));
|
|
57
|
+
});
|
|
42
58
|
}
|
|
43
59
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
(process.env.AGY_POWERLINE_DEBUG ? path.join(__dirname, "last-payload.json") : null);
|
|
48
|
-
if (dumpPath) {
|
|
49
|
-
writePayloadDump(dumpPath, input);
|
|
50
|
-
}
|
|
60
|
+
readStdin(STDIN_TIMEOUT_MS).then(({ text, complete }) => {
|
|
61
|
+
render(text.replace(/^\uFEFF/, "").trim(), complete);
|
|
62
|
+
});
|
|
51
63
|
|
|
52
|
-
|
|
64
|
+
function render(input, complete) {
|
|
65
|
+
if (!input) {
|
|
66
|
+
if (!complete) {
|
|
67
|
+
logWarning("STDIN_TIMEOUT", { ms: STDIN_TIMEOUT_MS });
|
|
68
|
+
}
|
|
69
|
+
process.exit(0);
|
|
70
|
+
}
|
|
53
71
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
process.exit(0);
|
|
62
|
-
}
|
|
72
|
+
// Debug dumps keep only the fields the bar renders, never personal ones.
|
|
73
|
+
const dumpPath =
|
|
74
|
+
dumpTarget(process.env.AGY_POWERLINE_DUMP_PAYLOAD, __dirname) ||
|
|
75
|
+
(process.env.AGY_POWERLINE_DEBUG ? path.join(__dirname, "last-payload.json") : null);
|
|
76
|
+
if (dumpPath) {
|
|
77
|
+
writePayloadDump(dumpPath, input);
|
|
78
|
+
}
|
|
63
79
|
|
|
64
|
-
|
|
65
|
-
if (typeof data?.version === "string" && /^\d[\w.+-]{0,30}$/.test(data.version)) {
|
|
66
|
-
setLogContext({ antigravity: data.version });
|
|
67
|
-
}
|
|
80
|
+
let data;
|
|
68
81
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
82
|
+
try {
|
|
83
|
+
data = JSON.parse(input);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
// Stdin was cut off by the deadline: nothing worth showing.
|
|
86
|
+
if (!complete) {
|
|
87
|
+
logWarning("STDIN_TIMEOUT", { ms: STDIN_TIMEOUT_MS });
|
|
88
|
+
process.exit(0);
|
|
89
|
+
}
|
|
90
|
+
logWarning("PAYLOAD_INVALID_JSON", { message: jsonErrorMessage(err.message) });
|
|
91
|
+
// Node's message quotes the bad input; only say where it broke.
|
|
92
|
+
process.stderr.write(`[agy-powerline] ${jsonErrorMessage(err.message)} on stdin\n`);
|
|
93
|
+
process.stdout.write("\x1b[31m[agy-powerline: invalid JSON]\x1b[0m\n");
|
|
94
|
+
process.exit(0);
|
|
95
|
+
}
|
|
73
96
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
process.stderr.write(`[agy-powerline] invalid statusline.config.json: ${configError}\n`);
|
|
79
|
-
}
|
|
97
|
+
// The one payload value the log keeps: Antigravity's version number.
|
|
98
|
+
if (typeof data?.version === "string" && /^\d[\w.+-]{0,30}$/.test(data.version)) {
|
|
99
|
+
setLogContext({ antigravity: data.version });
|
|
100
|
+
}
|
|
80
101
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
102
|
+
// The config lives next to this script. A copy anywhere other than the
|
|
103
|
+
// global install or AGY_POWERLINE_HOME (e.g. committed inside a repo) may be
|
|
104
|
+
// edited by someone else, so its exec widgets stay off.
|
|
105
|
+
const exec = isTrustedInstallDir(__dirname) ? "run" : "blocked";
|
|
84
106
|
|
|
85
|
-
if (
|
|
86
|
-
|
|
87
|
-
|
|
107
|
+
if (configError) {
|
|
108
|
+
logWarning("CONFIG_INVALID", {
|
|
109
|
+
message: /JSON/.test(configError) ? jsonErrorMessage(configError) : redact(configError),
|
|
110
|
+
});
|
|
111
|
+
process.stderr.write(`[agy-powerline] invalid statusline.config.json: ${configError}\n`);
|
|
112
|
+
}
|
|
88
113
|
|
|
89
|
-
|
|
114
|
+
const started = Date.now();
|
|
115
|
+
const output = renderStatusline(data, config, { exec, configError });
|
|
116
|
+
const elapsed = Date.now() - started;
|
|
117
|
+
|
|
118
|
+
if (elapsed > SLOW_RENDER_MS) {
|
|
119
|
+
logWarning("SLOW_RENDER", { ms: elapsed, widgets: config.widgets.map((widget) => widget.id) });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
process.stdout.write(output);
|
|
123
|
+
}
|