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 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 and tightest quota |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agy-powerline",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Interactive statusline for Google Antigravity",
5
5
  "bin": {
6
6
  "agy-powerline": "src/index.js"
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");
@@ -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 and tightest quota below",
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), { encoding: "utf8", flag: "wx" });
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",
@@ -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,
@@ -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;
@@ -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
  }
@@ -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
- "statusline.config.json",
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
- function writeFileAtomic(file, body) {
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
- fs.writeFileSync(tmp, body, "utf8");
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,
@@ -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
- const resolved = path.resolve(target);
164
- fs.mkdirSync(path.dirname(resolved), { recursive: true });
165
- fs.writeFileSync(resolved, JSON.stringify(body, null, 2) + "\n", "utf8");
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
  }
@@ -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,
@@ -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
- const input = fs
36
- .readFileSync(0, "utf8")
37
- .replace(/^\uFEFF/, "")
38
- .trim();
39
-
40
- if (!input) {
41
- process.exit(0);
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
- // Debug dumps keep only the fields the bar renders, never personal ones.
45
- const dumpPath =
46
- dumpTarget(process.env.AGY_POWERLINE_DUMP_PAYLOAD, __dirname) ||
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
- let data;
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
- try {
55
- data = JSON.parse(input);
56
- } catch (err) {
57
- logWarning("PAYLOAD_INVALID_JSON", { message: jsonErrorMessage(err.message) });
58
- // Node's message quotes the bad input; only say where it broke.
59
- process.stderr.write(`[agy-powerline] ${jsonErrorMessage(err.message)} on stdin\n`);
60
- process.stdout.write("\x1b[31m[agy-powerline: invalid JSON]\x1b[0m\n");
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
- // The one payload value the log keeps: Antigravity's version number.
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
- // The config lives next to this script. A copy anywhere other than the
70
- // global install or AGY_POWERLINE_HOME (e.g. committed inside a repo) may be
71
- // edited by someone else, so its exec widgets stay off.
72
- const exec = isTrustedInstallDir(__dirname) ? "run" : "blocked";
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
- if (configError) {
75
- logWarning("CONFIG_INVALID", {
76
- message: /JSON/.test(configError) ? jsonErrorMessage(configError) : redact(configError),
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
- const started = Date.now();
82
- const output = renderStatusline(data, config, { exec, configError });
83
- const elapsed = Date.now() - started;
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 (elapsed > SLOW_RENDER_MS) {
86
- logWarning("SLOW_RENDER", { ms: elapsed, widgets: config.widgets.map((widget) => widget.id) });
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
- process.stdout.write(output);
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
+ }