@higherdev/cli 0.32.0 → 0.34.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.
@@ -15,8 +15,9 @@ export function formatAttachmentBytes(size) {
15
15
  return `${Math.round(size / 1024)} KB`;
16
16
  return `${(size / 1024 / 1024).toFixed(1)} MB`;
17
17
  }
18
- export function attachmentChip(file) {
19
- return `[${file.mime.startsWith("image/") ? "image" : "file"}: ${file.name} ${formatAttachmentBytes(file.size)}]`;
18
+ export function attachmentChip(file, percent) {
19
+ const progress = file.size > 1024 * 1024 && percent != null ? ` ${percent}%` : "";
20
+ return `[${file.mime.startsWith("image/") ? "image" : "file"}: ${file.name} ${formatAttachmentBytes(file.size)}${progress}]`;
20
21
  }
21
22
  function shellWords(text) {
22
23
  const words = [];
@@ -108,7 +109,7 @@ export async function detectDroppedPaths(pasted, isFile = async (path) => (await
108
109
  const checks = await Promise.all(words.map((path) => isFile(path).catch(() => false)));
109
110
  return checks.every(Boolean) ? words : [];
110
111
  }
111
- export async function uploadAttachment(path, target = {}, config = loadConfig()) {
112
+ export async function uploadAttachment(path, target = {}, config = loadConfig(), fetchImpl = fetch) {
112
113
  const info = await stat(path);
113
114
  if (!info.isFile())
114
115
  throw new Error(`${path} is not a file.`);
@@ -117,17 +118,49 @@ export async function uploadAttachment(path, target = {}, config = loadConfig())
117
118
  const mime = MIME[extname(path).toLowerCase()];
118
119
  if (!mime)
119
120
  throw new Error("Use an image, PDF, text, Markdown, JSON, or CSV file.");
120
- const form = new FormData();
121
- form.set("file", new Blob([await readFile(path)], { type: mime }), basename(path));
122
- if (target.ticketKey)
123
- form.set("ticket_key", target.ticketKey.toUpperCase());
124
- if (target.messageId)
125
- form.set("message_id", target.messageId);
126
- const response = await fetch(`${config.url}/api/w/${config.slug}/attachments`, {
127
- method: "POST", headers: { authorization: `Bearer ${config.api_key}` }, body: form,
121
+ const name = basename(path);
122
+ const bytes = await readFile(path);
123
+ const response = await fetchImpl(`${config.url}/api/w/${config.slug}/attachments/sign`, {
124
+ method: "POST", headers: { authorization: `Bearer ${config.api_key}`, "content-type": "application/json" },
125
+ body: JSON.stringify({ name, mime, size: info.size }),
128
126
  });
129
- const parsed = await response.json().catch(() => null);
130
- if (!response.ok || !parsed?.attachment)
131
- throw new Error(parsed?.error ?? `hd: ${response.status} Upload failed.`);
132
- return parsed.attachment;
127
+ const signed = await response.json().catch(() => null);
128
+ if (!response.ok || !signed?.attachment || !signed.signedUrl) {
129
+ throw new Error(signed?.error ?? `hd: ${response.status} Upload signing failed.`);
130
+ }
131
+ target.onProgress?.({ name, mime, size: info.size, percent: 0 });
132
+ const uploadResponse = await fetchImpl(signed.signedUrl, {
133
+ method: "PUT", headers: { "cache-control": "max-age=3600", "content-type": mime, "x-upsert": "false" },
134
+ body: progressStream(bytes, (percent) => target.onProgress?.({ name, mime, size: info.size, percent })),
135
+ duplex: "half",
136
+ });
137
+ const completeUrl = `${config.url}/api/w/${config.slug}/attachments/${signed.attachment.id}/complete`;
138
+ if (!uploadResponse.ok) {
139
+ await fetchImpl(completeUrl, { method: "POST", headers: { authorization: `Bearer ${config.api_key}` } }).catch(() => undefined);
140
+ throw new Error(`hd: ${uploadResponse.status} Direct attachment upload failed.`);
141
+ }
142
+ const completedResponse = await fetchImpl(completeUrl, {
143
+ method: "POST", headers: { authorization: `Bearer ${config.api_key}`, "content-type": "application/json" },
144
+ body: JSON.stringify({ ...(target.ticketKey ? { ticket_key: target.ticketKey.toUpperCase() } : {}),
145
+ ...(target.messageId ? { message_id: target.messageId } : {}) }),
146
+ });
147
+ const completed = await completedResponse.json().catch(() => null);
148
+ if (!completedResponse.ok || !completed?.attachment) {
149
+ throw new Error(completed?.error ?? `hd: ${completedResponse.status} Upload completion failed.`);
150
+ }
151
+ target.onProgress?.({ name, mime, size: info.size, percent: 100 });
152
+ return completed.attachment;
153
+ }
154
+ function progressStream(bytes, onProgress) {
155
+ let offset = 0;
156
+ return new ReadableStream({ pull(controller) {
157
+ if (offset >= bytes.byteLength) {
158
+ controller.close();
159
+ return;
160
+ }
161
+ const end = Math.min(offset + 64 * 1024, bytes.byteLength);
162
+ controller.enqueue(bytes.subarray(offset, end));
163
+ offset = end;
164
+ onProgress(bytes.byteLength ? Math.round(offset * 100 / bytes.byteLength) : 100);
165
+ } });
133
166
  }
package/dist/index.js CHANGED
@@ -69,6 +69,10 @@ async function cmdStatus() {
69
69
  if (data.host?.draining) {
70
70
  console.log(`${c.yellow(formatDrainStatus(data.host.draining.live, data.host.draining.until))}\n`);
71
71
  }
72
+ const signedOut = data.host?.signed_out ?? [];
73
+ if (signedOut.length) {
74
+ console.log(`${c.bold("Host")} ${signedOut.map((row) => c.yellow(row.reason)).join("\n ")}\n`);
75
+ }
72
76
  const waiting = [...new Set(data.tickets.map((ticket) => ticket.stuck_reason
73
77
  ?.match(/^Waiting on (\w+) until (\d{1,2}:\d{2})\.$/)).filter(Boolean)
74
78
  .map((match) => `${match?.[1]} waiting until ${match?.[2]}`))];
package/dist/tui/App.js CHANGED
@@ -56,6 +56,7 @@ export function App({ initial, availableUpdate: initialUpdate = null, updateDeps
56
56
  const [now, setNow] = useState(Date.now());
57
57
  const [draft, setDraft] = useState("");
58
58
  const [pendingAttachments, setPendingAttachments] = useState([]);
59
+ const [attachmentUploads, setAttachmentUploads] = useState({});
59
60
  const [busy, setBusy] = useState(false);
60
61
  const [notice, setNotice] = useState(null);
61
62
  const [availableUpdate, setAvailableUpdate] = useState(initialUpdate);
@@ -467,8 +468,18 @@ export function App({ initial, availableUpdate: initialUpdate = null, updateDeps
467
468
  setBusy(true);
468
469
  try {
469
470
  const uploaded = [];
470
- for (const path of paths)
471
- uploaded.push(await uploadAttachment(path, {}, config));
471
+ for (const path of paths) {
472
+ try {
473
+ uploaded.push(await uploadAttachment(path, { onProgress: (progress) => setAttachmentUploads((current) => ({ ...current, [path]: progress })) }, config));
474
+ }
475
+ finally {
476
+ setAttachmentUploads((current) => {
477
+ const next = { ...current };
478
+ delete next[path];
479
+ return next;
480
+ });
481
+ }
482
+ }
472
483
  setPendingAttachments((current) => [...current, ...uploaded]);
473
484
  }
474
485
  catch (error) {
@@ -997,7 +1008,7 @@ export function App({ initial, availableUpdate: initialUpdate = null, updateDeps
997
1008
  return _jsx(Bubble, { message: item.message, width: width }, item.key);
998
1009
  } }), splash ? (_jsx(Splash, { columns: columns, rows: rows, width: width, ready: ready, helpFull: plan.helpFull, animate: plan.fits, onDone: () => setReady(true) })) : null, _jsxs(Box, { flexDirection: "column", width: width, children: [view === "board" && plan.panels > 0 ? _jsx(BoardPanel, { board: board, width: width, rows: plan.panels, cursor: cursor }) : null, view === "roadmap" && plan.panels > 0 ? (_jsx(RoadmapPanel, { board: board, width: width, rows: plan.panels, offset: roadmapOffset })) : null, view === "agents" && plan.panels > 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(AgentsPanel, { board: board, width: width, rows: agentsView.top }), agentsView.bottom > 0 ? _jsxs(_Fragment, { children: [_jsx(Box, { height: 1 }), _jsx(StreamPanel, { lines: stream, width: width, rows: agentsView.bottom, live: running > 0 })] }) : null] })) : null, view === "feed" && plan.panels > 0 ? _jsx(FeedPanel, { entries: feed, width: width, rows: plan.panels }) : null, view === "settings" && plan.panels > 0 ? _jsx(SettingsPanel, { entries: settings, width: width, rows: plan.panels, title: "Settings", cursor: field, editing: editing }) : null, view === "inbox" && plan.panels > 0 ? _jsx(InboxPanel, { board: board, width: width, rows: plan.panels, focus: inboxFocus, selectedId: selectedDecisionId, answeringId: answering }) : null, view === "ticket" && plan.panels > 0 ? ticket
999
1010
  ? _jsx(TicketPanel, { ticket: ticket, width: width, rows: plan.panels, offset: ticketOffset, collapsed: ticketCollapsed })
1000
- : _jsxs(Text, { color: UI.warn, children: ["No ticket ", ticketKey, " here."] }) : null, view === "home" && plan.cockpit > 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Cockpit, { board: board, width: width, rows: plan.cockpit, cursor: cursor }), _jsx(Box, { height: 1 }), _jsx(StreamPanel, { lines: stream, width: width, rows: plan.stream, live: running > 0 })] })) : null, chatting && plan.panels > 0 && activeThread ? (_jsx(ChatPanel, { thread: activeThread, width: width, rows: plan.panels, offset: chatOffset, label: chatLabel, now: now })) : null, chatting ? null : _jsx(DecisionPanel, { decisions: decisions, board: board, width: width, rows: plan.decision, selectedId: selectedDecisionId }), notice ? _jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: UI.warn, children: notice }) }) : null, _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: UI.text, bold: true, children: workspace.slug }), _jsxs(Text, { color: UI.dim, children: [" ", workspace.repo, " "] }), _jsx(Text, { color: live === "live" ? UI.accent : UI.dim, children: "\u25CF " }), _jsxs(Text, { color: UI.dim, children: [live === "live" ? "5s poll" : live, " "] }), _jsx(Text, { color: UI.dim, children: running ? `${running} running ` : "" }), board.decisions.length ? _jsxs(Text, { color: UI.warn, children: [board.decisions.length, " decisions "] }) : null, workspace.paused ? _jsx(Text, { color: UI.warn, children: "paused " }) : null, logsFilter || rawLogs ? _jsxs(Text, { color: UI.warn, children: ["logs ", rawLogs ? "raw " : "", logsFilter ?? "all", " "] }) : null, _jsxs(Text, { color: UI.dim, wrap: "truncate", children: ["\u00B7 ", chatting ? chatLabel : mode, answering ? " esc cancels" : chatting ? " ↑↓ scroll · pgup/pgdn · esc hides" : view === "ticket" ? " ↑↓ scroll · pgup/pgdn · enter folds" : view === "roadmap" ? " ↑↓ scroll · pgup/pgdn" : view === "inbox" ? " ↑↓ decisions · enter answers" : cursor && selected ? ` ${selected.key} ↑↓ move · enter opens · esc leaves` : ""] })] }), availableUpdate || updateProgress ? (_jsx(Box, { children: _jsx(Text, { color: UI.warn, children: updateProgress ?? tuiUpdatePrompt(availableUpdate) }) })) : null, pendingAttachments.map((attachment) => (_jsx(Text, { color: UI.accent, children: attachmentChip(attachment) }, attachment.id))), _jsx(Box, { children: _jsx(TextInput, { value: draft, onChange: (next) => {
1011
+ : _jsxs(Text, { color: UI.warn, children: ["No ticket ", ticketKey, " here."] }) : null, view === "home" && plan.cockpit > 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Cockpit, { board: board, width: width, rows: plan.cockpit, cursor: cursor }), _jsx(Box, { height: 1 }), _jsx(StreamPanel, { lines: stream, width: width, rows: plan.stream, live: running > 0 })] })) : null, chatting && plan.panels > 0 && activeThread ? (_jsx(ChatPanel, { thread: activeThread, width: width, rows: plan.panels, offset: chatOffset, label: chatLabel, now: now })) : null, chatting ? null : _jsx(DecisionPanel, { decisions: decisions, board: board, width: width, rows: plan.decision, selectedId: selectedDecisionId }), notice ? _jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: UI.warn, children: notice }) }) : null, _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: UI.text, bold: true, children: workspace.slug }), _jsxs(Text, { color: UI.dim, children: [" ", workspace.repo, " "] }), _jsx(Text, { color: live === "live" ? UI.accent : UI.dim, children: "\u25CF " }), _jsxs(Text, { color: UI.dim, children: [live === "live" ? "5s poll" : live, " "] }), _jsx(Text, { color: UI.dim, children: running ? `${running} running ` : "" }), board.decisions.length ? _jsxs(Text, { color: UI.warn, children: [board.decisions.length, " decisions "] }) : null, workspace.paused ? _jsx(Text, { color: UI.warn, children: "paused " }) : null, logsFilter || rawLogs ? _jsxs(Text, { color: UI.warn, children: ["logs ", rawLogs ? "raw " : "", logsFilter ?? "all", " "] }) : null, _jsxs(Text, { color: UI.dim, wrap: "truncate", children: ["\u00B7 ", chatting ? chatLabel : mode, answering ? " esc cancels" : chatting ? " ↑↓ scroll · pgup/pgdn · esc hides" : view === "ticket" ? " ↑↓ scroll · pgup/pgdn · enter folds" : view === "roadmap" ? " ↑↓ scroll · pgup/pgdn" : view === "inbox" ? " ↑↓ decisions · enter answers" : cursor && selected ? ` ${selected.key} ↑↓ move · enter opens · esc leaves` : ""] })] }), availableUpdate || updateProgress ? (_jsx(Box, { children: _jsx(Text, { color: UI.warn, children: updateProgress ?? tuiUpdatePrompt(availableUpdate) }) })) : null, pendingAttachments.map((attachment) => (_jsx(Text, { color: UI.accent, children: attachmentChip(attachment) }, attachment.id))), Object.entries(attachmentUploads).map(([path, upload]) => (_jsx(Text, { color: UI.accent, children: attachmentChip(upload, upload.percent) }, path))), _jsx(Box, { children: _jsx(TextInput, { value: draft, onChange: (next) => {
1001
1012
  setDraft(next);
1002
1013
  if (editingRef.current)
1003
1014
  setEditing({ key: editingRef.current.key, draft: next });
@@ -118,11 +118,13 @@ export function AgentsColumn({ board, width, rows, }) {
118
118
  ]
119
119
  : []),
120
120
  ...shown.map((row) => {
121
- const tone = row.run ? "blue" : row.state === "offline" || row.state === "limited" || row.state === "draining"
121
+ const tone = row.run ? "blue" : row.state === "offline" || row.state === "limited"
122
+ || row.state === "draining" || row.state === "signed_out"
122
123
  ? "warning" : "muted";
123
124
  return (_jsxs(Box, { flexWrap: "nowrap", children: [_jsxs(Text, { color: inkColor(tone), children: [DOT, " "] }), _jsxs(Text, { color: UI.text, wrap: "truncate", children: [row.name, row.state === "draining" ? null : (_jsx(Text, { color: UI.dim, children: row.run
124
125
  ? ` · ${row.ticket?.key ?? row.run.kind} ${elapsed(row.run.started_at ?? row.run.created_at)}`
125
- : ` · ${row.limitedUntil ? `limited until ${row.limitedUntil}` : row.detail ?? row.state}` }))] })] }, row.key));
126
+ : ` · ${row.state === "signed_out" ? "signed out"
127
+ : row.limitedUntil ? `limited until ${row.limitedUntil}` : row.detail ?? row.state}` }))] })] }, row.key));
126
128
  }),
127
129
  _jsx(More, { count: displayRows.length - shown.length }, "more"),
128
130
  ] }));
@@ -28,10 +28,12 @@ export function AgentsPanel({ board, width = 80, rows = 12, }) {
28
28
  ]
29
29
  : []),
30
30
  ...shown.map((row) => {
31
- const tone = row.run ? "blue" : row.state === "offline" || row.state === "limited" || row.state === "draining"
31
+ const tone = row.run ? "blue" : row.state === "offline" || row.state === "limited"
32
+ || row.state === "draining" || row.state === "signed_out"
32
33
  ? "warning" : "muted";
33
34
  const state = row.state === "draining" ? ""
34
- : row.limitedUntil ? `limited until ${row.limitedUntil}` : row.state;
35
+ : row.state === "signed_out" ? "signed out"
36
+ : row.limitedUntil ? `limited until ${row.limitedUntil}` : row.state;
35
37
  return (_jsxs(Text, { wrap: "truncate", children: [_jsxs(Text, { color: inkColor(tone), children: [DOT, " "] }), _jsx(Text, { color: UI.text, children: pad(truncate(row.name, 13), 14) }), _jsx(Text, { color: UI.dim, children: pad(row.agent?.role ?? row.run?.kind ?? "", 13) }), _jsx(Text, { color: UI.dim, children: pad(truncate(row.agent?.model ?? "", 23), 24) }), _jsx(Text, { color: UI.text, children: pad(state, row.limitedUntil ? 22 : 9) }), _jsxs(Text, { color: UI.dim, children: [row.ticket ? `${row.ticket.key} ` : "", row.run ? elapsed(row.run.started_at ?? row.run.created_at) : ""] })] }, row.key));
36
38
  }),
37
39
  _jsx(More, { count: displayRows.length - shown.length }, "more"),
@@ -1,6 +1,7 @@
1
1
  import { formatDrainStatus } from "../host.js";
2
2
  import { orchestratorIdleReason } from "../roadmap.js";
3
3
  const LIMITED_UNTIL = /^(?:Waiting on )?(\w+) (?:limited )?until (\d{1,2}:\d{2})\.?$/;
4
+ const SIGNED_OUT = /^(?:Claude Code|Codex|Grok|Gemini) on host \S+ is signed out/;
4
5
  export function limitedUntilByProvider(tickets) {
5
6
  const limited = new Map();
6
7
  for (const ticket of tickets) {
@@ -10,6 +11,15 @@ export function limitedUntilByProvider(tickets) {
10
11
  }
11
12
  return limited;
12
13
  }
14
+ export function signedOutProviders(board) {
15
+ const providers = new Set((board.hostSignedOut ?? []).map((row) => row.provider));
16
+ for (const ticket of board.tickets) {
17
+ if (ticket.stuck_reason && SIGNED_OUT.test(ticket.stuck_reason) && ticket.provider) {
18
+ providers.add(ticket.provider);
19
+ }
20
+ }
21
+ return providers;
22
+ }
13
23
  const roleForKind = {
14
24
  architect: "architect",
15
25
  build: "builder",
@@ -67,24 +77,30 @@ export function agentDisplayRows(board, now = Date.now()) {
67
77
  };
68
78
  });
69
79
  const limited = limitedUntilByProvider(board.tickets);
80
+ const signedOut = signedOutProviders(board);
70
81
  const idle = board.agents
71
82
  .filter((agent) => agent.enabled && !activeAgents.has(agent.id))
72
83
  .sort((left, right) => left.display_name.localeCompare(right.display_name))
73
84
  .map((agent) => {
74
85
  const until = limited.get(agent.provider);
86
+ const out = signedOut.has(agent.provider);
75
87
  return {
76
88
  key: `idle:${agent.id}`,
77
89
  name: agent.display_name,
78
90
  agent,
79
91
  run: null,
80
92
  ticket: null,
81
- state: until
82
- ? "limited"
83
- : board.availability.some((row) => row.provider === agent.provider && !row.available)
84
- ? "offline"
85
- : "idle",
93
+ state: out
94
+ ? "signed_out"
95
+ : until
96
+ ? "limited"
97
+ : board.availability.some((row) => row.provider === agent.provider && !row.available)
98
+ ? "offline"
99
+ : "idle",
86
100
  limitedUntil: until ?? null,
87
- detail: agent.role === "orchestrator" ? orchestratorIdleReason(board.epics) : null,
101
+ detail: out
102
+ ? "signed out"
103
+ : agent.role === "orchestrator" ? orchestratorIdleReason(board.epics) : null,
88
104
  };
89
105
  });
90
106
  return [...drain, ...live, ...idle];
package/dist/tui/data.js CHANGED
@@ -84,6 +84,7 @@ export async function loadSnapshot(config = loadConfig(), options = {}) {
84
84
  availability,
85
85
  runs: status.recent_runs ?? status.live_runs,
86
86
  hostDrain: status.host?.draining ?? null,
87
+ hostSignedOut: status.host?.signed_out ?? [],
87
88
  },
88
89
  feed: feedData.entries,
89
90
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@higherdev/cli",
3
- "version": "0.32.0",
3
+ "version": "0.34.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",