pi-famulus 0.1.1 → 0.1.2
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 +9 -10
- package/src/bash-override.ts +89 -9
- package/src/manager-client.ts +1 -1
- package/src/subagent/fleet-widget.ts +46 -6
- package/src/subagent/pi-runtime.ts +5 -3
- package/src/tui/tasks-command.ts +36 -6
- package/src/work-index.ts +16 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-famulus",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Subagents, monitoring, and auto-backgrounding bash for pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -15,10 +15,10 @@
|
|
|
15
15
|
"files": ["src", "bin", "README.md"],
|
|
16
16
|
"bin": { "pi-famulus": "./bin/pi-famulus.js" },
|
|
17
17
|
"optionalDependencies": {
|
|
18
|
-
"pi-famulus-linux-x64": "0.1.
|
|
19
|
-
"pi-famulus-linux-arm64": "0.1.
|
|
20
|
-
"pi-famulus-darwin-x64": "0.1.
|
|
21
|
-
"pi-famulus-darwin-arm64": "0.1.
|
|
18
|
+
"pi-famulus-linux-x64": "0.1.2",
|
|
19
|
+
"pi-famulus-linux-arm64": "0.1.2",
|
|
20
|
+
"pi-famulus-darwin-x64": "0.1.2",
|
|
21
|
+
"pi-famulus-darwin-arm64": "0.1.2"
|
|
22
22
|
},
|
|
23
23
|
"pi": {
|
|
24
24
|
"extensions": ["./src/index.ts"]
|
|
@@ -27,18 +27,17 @@
|
|
|
27
27
|
"test": "vitest run",
|
|
28
28
|
"typecheck": "tsc --noEmit"
|
|
29
29
|
},
|
|
30
|
-
"dependencies": {
|
|
31
|
-
"typebox": "^1.3.7"
|
|
32
|
-
},
|
|
33
30
|
"devDependencies": {
|
|
34
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
35
|
-
"@earendil-works/pi-tui": "^0.
|
|
31
|
+
"@earendil-works/pi-coding-agent": "1.0.0",
|
|
32
|
+
"@earendil-works/pi-tui": "^1.0.0",
|
|
36
33
|
"@types/node": "^24.0.0",
|
|
34
|
+
"typebox": "1.3.27",
|
|
37
35
|
"typescript": "7.0.2",
|
|
38
36
|
"vitest": "^3.0.0"
|
|
39
37
|
},
|
|
40
38
|
"peerDependencies": {
|
|
41
39
|
"@earendil-works/pi-coding-agent": "*",
|
|
40
|
+
"typebox": "*",
|
|
42
41
|
"@earendil-works/pi-tui": "*"
|
|
43
42
|
},
|
|
44
43
|
"peerDependenciesMeta": {
|
package/src/bash-override.ts
CHANGED
|
@@ -17,7 +17,7 @@ import { randomUUID } from "node:crypto";
|
|
|
17
17
|
import { writeFileSync } from "node:fs";
|
|
18
18
|
import { tmpdir } from "node:os";
|
|
19
19
|
import { join } from "node:path";
|
|
20
|
-
import { Type } from "typebox";
|
|
20
|
+
import { Type, type Static } from "typebox";
|
|
21
21
|
import type {
|
|
22
22
|
AgentToolResult,
|
|
23
23
|
BashToolDetails,
|
|
@@ -62,6 +62,46 @@ const bashParameters = Type.Object({
|
|
|
62
62
|
|
|
63
63
|
type BashParams = { command: string; timeout?: number; run_in_background?: boolean };
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Mirrors the built-in bash outputSchema (pi ≥0.99) so codemode scripts calling
|
|
67
|
+
* `bash()` resolve to the same structured shape the built-in tool returns.
|
|
68
|
+
* pi does not export `bashOutputSchema`, so keep this in sync with BashToolOutput.
|
|
69
|
+
*/
|
|
70
|
+
const bashOutputSchema = Type.Object({
|
|
71
|
+
output: Type.String(),
|
|
72
|
+
truncated: Type.Boolean(),
|
|
73
|
+
full_output_path: Type.Optional(Type.String()),
|
|
74
|
+
exit_code: Type.Number(),
|
|
75
|
+
wall_time_seconds: Type.Number(),
|
|
76
|
+
});
|
|
77
|
+
type BashToolOutput = Static<typeof bashOutputSchema>;
|
|
78
|
+
|
|
79
|
+
/** Same cap as the built-in structured output (1 MiB). */
|
|
80
|
+
const STRUCTURED_OUTPUT_MAX_BYTES = 1024 * 1024;
|
|
81
|
+
|
|
82
|
+
/** Full-output slice for structuredContent, keeping the tail like the built-in. */
|
|
83
|
+
function structuredOutput(fullText: string): { output: string; truncated: boolean } {
|
|
84
|
+
if (Buffer.byteLength(fullText, "utf8") <= STRUCTURED_OUTPUT_MAX_BYTES) {
|
|
85
|
+
return { output: fullText, truncated: false };
|
|
86
|
+
}
|
|
87
|
+
// Keep the largest tail that fits the cap without materializing the whole
|
|
88
|
+
// output as a second buffer: search a UTF-16 index near the end (each code
|
|
89
|
+
// unit encodes to at least one byte, so the tail starts within `cap` units
|
|
90
|
+
// of the end) and only encode that slice.
|
|
91
|
+
let lo = Math.max(0, fullText.length - STRUCTURED_OUTPUT_MAX_BYTES);
|
|
92
|
+
let hi = fullText.length;
|
|
93
|
+
while (lo < hi) {
|
|
94
|
+
const mid = (lo + hi) >> 1;
|
|
95
|
+
if (Buffer.byteLength(fullText.slice(mid), "utf8") <= STRUCTURED_OUTPUT_MAX_BYTES) hi = mid;
|
|
96
|
+
else lo = mid + 1;
|
|
97
|
+
}
|
|
98
|
+
return { output: fullText.slice(lo), truncated: true };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function wallSeconds(startedAtMs: number, endedAtMs: number): number {
|
|
102
|
+
return Math.round((endedAtMs - startedAtMs) / 100) / 10;
|
|
103
|
+
}
|
|
104
|
+
|
|
65
105
|
/** Details shape returned by this override; superset of BashToolDetails. */
|
|
66
106
|
export interface FamulusBashDetails extends BashToolDetails {
|
|
67
107
|
backgrounded?: boolean;
|
|
@@ -130,6 +170,7 @@ async function executeLocal(
|
|
|
130
170
|
clock: Clock,
|
|
131
171
|
): Promise<AgentToolResult<FamulusBashDetails | undefined>> {
|
|
132
172
|
const timeoutMs = resolveTimeoutMs(params.timeout);
|
|
173
|
+
const startedAtMs = clock.now();
|
|
133
174
|
const shell = process.env.SHELL && process.env.SHELL.length > 0 ? process.env.SHELL : "/bin/bash";
|
|
134
175
|
|
|
135
176
|
const output = await new Promise<{ text: string; exitCode: number | null; signal: string | null; timedOut: boolean; aborted: boolean }>(
|
|
@@ -216,11 +257,26 @@ async function executeLocal(
|
|
|
216
257
|
if (output.timedOut && params.timeout !== undefined) {
|
|
217
258
|
throw new Error(appendStatus(text, timedOutStatus(params.timeout)));
|
|
218
259
|
}
|
|
219
|
-
if (output.exitCode !== 0 && output.exitCode !== null) {
|
|
220
|
-
throw new Error(appendStatus(text, `Command exited with code ${output.exitCode}`));
|
|
221
|
-
}
|
|
222
260
|
if (output.exitCode === null) throw new Error(appendStatus(text, killedBy(output.signal)));
|
|
223
|
-
|
|
261
|
+
const { output: structuredText, truncated: structuredTruncated } = structuredOutput(output.text);
|
|
262
|
+
const structuredContent: BashToolOutput = {
|
|
263
|
+
output: structuredText,
|
|
264
|
+
truncated: structuredTruncated,
|
|
265
|
+
...(details?.fullOutputPath ? { full_output_path: details.fullOutputPath } : {}),
|
|
266
|
+
exit_code: output.exitCode,
|
|
267
|
+
wall_time_seconds: wallSeconds(startedAtMs, clock.now()),
|
|
268
|
+
};
|
|
269
|
+
if (output.exitCode !== 0) {
|
|
270
|
+
// Mirror the built-in bash tool: non-zero exits resolve to an isError
|
|
271
|
+
// result carrying structuredContent instead of rejecting.
|
|
272
|
+
return {
|
|
273
|
+
content: [{ type: "text", text: appendStatus(text, `Command exited with code ${output.exitCode}`) }],
|
|
274
|
+
details,
|
|
275
|
+
structuredContent,
|
|
276
|
+
isError: true,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
return { content: [{ type: "text", text }], details, structuredContent };
|
|
224
280
|
}
|
|
225
281
|
|
|
226
282
|
// ---------------------------------------------------------------------------
|
|
@@ -245,6 +301,7 @@ export function createBashOverride(
|
|
|
245
301
|
"Long-running bash commands are moved to the background automatically; do not poll or sleep to wait for them. End your turn (a reply with no tool call) and resume from the task wake when it arrives.",
|
|
246
302
|
],
|
|
247
303
|
parameters: bashParameters,
|
|
304
|
+
outputSchema: bashOutputSchema,
|
|
248
305
|
renderResult(result, { expanded }, theme, context) {
|
|
249
306
|
const details = result.details as FamulusBashDetails | undefined;
|
|
250
307
|
if (details?.backgrounded && details.task_id) {
|
|
@@ -291,6 +348,7 @@ export function createBashOverride(
|
|
|
291
348
|
}
|
|
292
349
|
|
|
293
350
|
const timeoutMs = resolveTimeoutMs(input.timeout);
|
|
351
|
+
const startedAtMs = (deps.clock ?? realClock).now();
|
|
294
352
|
let start;
|
|
295
353
|
try {
|
|
296
354
|
start = await client.start({
|
|
@@ -358,15 +416,37 @@ export function createBashOverride(
|
|
|
358
416
|
const collected = await collectOutput(client, start.task_id);
|
|
359
417
|
const { text, details } = formatFinishedOutput(collected, outputPath);
|
|
360
418
|
const exitCode = waitResult.exit_code ?? null;
|
|
361
|
-
if (exitCode !== 0 && exitCode !== null) {
|
|
362
|
-
throw new Error(appendStatus(text, `Command exited with code ${exitCode}`));
|
|
363
|
-
}
|
|
364
419
|
// No exit code: killed (timeout, stop, crash), unless the manager
|
|
365
420
|
// finished it as completed with its runner status unobservable.
|
|
366
421
|
if (exitCode === null && collected.status !== "completed") {
|
|
367
422
|
throw new Error(appendStatus(text, await killedStatus(client, start.task_id, input.timeout)));
|
|
368
423
|
}
|
|
369
|
-
|
|
424
|
+
if (exitCode === null) {
|
|
425
|
+
// Completed but the exit code was unobservable: no honest
|
|
426
|
+
// structured shape (exit_code is required, and a sentinel like -1
|
|
427
|
+
// would misclassify success as failure), so omit structuredContent
|
|
428
|
+
// and let codemode scripts fall back to the text content.
|
|
429
|
+
return { content: [{ type: "text", text }], details };
|
|
430
|
+
}
|
|
431
|
+
const { output: structuredText, truncated: structuredTruncated } = structuredOutput(collected.text);
|
|
432
|
+
const structuredContent: BashToolOutput = {
|
|
433
|
+
output: structuredText,
|
|
434
|
+
truncated: structuredTruncated || collected.windowed,
|
|
435
|
+
full_output_path: outputPath,
|
|
436
|
+
exit_code: exitCode,
|
|
437
|
+
wall_time_seconds: wallSeconds(startedAtMs, (deps.clock ?? realClock).now()),
|
|
438
|
+
};
|
|
439
|
+
if (exitCode !== 0) {
|
|
440
|
+
// Mirror the built-in bash tool: non-zero exits resolve to an isError
|
|
441
|
+
// result carrying structuredContent instead of rejecting.
|
|
442
|
+
return {
|
|
443
|
+
content: [{ type: "text", text: appendStatus(text, `Command exited with code ${exitCode}`) }],
|
|
444
|
+
details,
|
|
445
|
+
structuredContent,
|
|
446
|
+
isError: true,
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
return { content: [{ type: "text", text }], details, structuredContent };
|
|
370
450
|
},
|
|
371
451
|
};
|
|
372
452
|
}
|
package/src/manager-client.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { famulusPaths } from "./config";
|
|
|
16
16
|
import { realClock, type Clock, type ClockTimer } from "./clock";
|
|
17
17
|
|
|
18
18
|
const MAX_FRAME_BYTES = 4 * 1024 * 1024; // 4 MiB (§3.3)
|
|
19
|
-
const EXTENSION_VERSION = "0.1.
|
|
19
|
+
const EXTENSION_VERSION = "0.1.2";
|
|
20
20
|
// 3: speaks in-place upgrade (resends on reconnect, start keys).
|
|
21
21
|
const PROTOCOL = 3;
|
|
22
22
|
const HELLO_TIMEOUT_MS = 5000;
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* Driven by WorkIndex changes. Does not poll the manager.
|
|
10
10
|
*/
|
|
11
11
|
import { realClock, type Clock, type ClockTimer } from "../clock";
|
|
12
|
-
import { formatAge, type WorkIndex, type WorkItem } from "../work-index";
|
|
12
|
+
import { formatAge, nextAgeBoundary, type WorkIndex, type WorkItem } from "../work-index";
|
|
13
13
|
import { truncateToWidth } from "../tui/pi-tui-load";
|
|
14
14
|
|
|
15
15
|
export const FLEET_WIDGET_KEY = "pi-famulus-fleet";
|
|
@@ -72,6 +72,7 @@ export class FleetWidget {
|
|
|
72
72
|
private tui: FleetTui | null = null;
|
|
73
73
|
private unsubscribe: (() => void) | null = null;
|
|
74
74
|
private ageTimer: ClockTimer | null = null;
|
|
75
|
+
private ageTimerIsTimeout = false;
|
|
75
76
|
private readonly clock: Clock;
|
|
76
77
|
|
|
77
78
|
constructor(deps: FleetStatusDeps) {
|
|
@@ -93,10 +94,11 @@ export class FleetWidget {
|
|
|
93
94
|
const counts = this.deps.index.counts();
|
|
94
95
|
const activeAgents = items.filter((item) => item.kind === "agent" && isActive(item));
|
|
95
96
|
const total = counts.workers + counts.subagents + counts.monitors;
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
97
|
+
// Always reschedule: an item added while a timeout is pending may have
|
|
98
|
+
// an earlier boundary, and a removed item must not tick for stale state.
|
|
99
|
+
if (activeAgents.length > 0) {
|
|
100
|
+
this.startAgeTicker();
|
|
101
|
+
} else {
|
|
100
102
|
this.clearAgeTimer();
|
|
101
103
|
}
|
|
102
104
|
if (total === 0) {
|
|
@@ -109,6 +111,11 @@ export class FleetWidget {
|
|
|
109
111
|
(tui, theme) => {
|
|
110
112
|
this.tui = tui;
|
|
111
113
|
this.widgetRegistered = true;
|
|
114
|
+
// The ticker refuses to schedule while no component is mounted
|
|
115
|
+
// (refresh may have run before the host invoked the factory).
|
|
116
|
+
if (!this.ageTimer && this.deps.index.list(this.clock.now()).some((item) => item.kind === "agent" && isActive(item))) {
|
|
117
|
+
this.scheduleNextAgeTick();
|
|
118
|
+
}
|
|
112
119
|
return {
|
|
113
120
|
render: (width) => this.renderLine(width, theme),
|
|
114
121
|
invalidate: () => {},
|
|
@@ -136,10 +143,43 @@ export class FleetWidget {
|
|
|
136
143
|
if (ui) this.clearWidget(ui);
|
|
137
144
|
}
|
|
138
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Tick exactly when a displayed age changes. formatAge rounds
|
|
148
|
+
* (now - startedAt) to the nearest second, so each live item's visible
|
|
149
|
+
* value flips on half-second boundaries relative to its own startedAt —
|
|
150
|
+
* a global phase would be early/late for items started off the second.
|
|
151
|
+
* Schedule the earliest pending flip and re-evaluate after every tick.
|
|
152
|
+
*/
|
|
153
|
+
private startAgeTicker(): void {
|
|
154
|
+
this.clearAgeTimer();
|
|
155
|
+
this.scheduleNextAgeTick();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private scheduleNextAgeTick(): void {
|
|
159
|
+
const now = this.clock.now();
|
|
160
|
+
const items = this.deps.index.list(now).filter((item) => item.kind === "agent" && isActive(item));
|
|
161
|
+
if (items.length === 0 || !this.tui) return;
|
|
162
|
+
let next = Infinity;
|
|
163
|
+
for (const item of items) {
|
|
164
|
+
next = Math.min(next, nextAgeBoundary(now, item.startedAt));
|
|
165
|
+
}
|
|
166
|
+
this.ageTimerIsTimeout = true;
|
|
167
|
+
this.ageTimer = this.clock.setTimeout(() => {
|
|
168
|
+
this.ageTimer = null;
|
|
169
|
+
this.ageTimerIsTimeout = false;
|
|
170
|
+
this.tui?.requestRender();
|
|
171
|
+
// Phases and the active set may have changed; schedule the next flip.
|
|
172
|
+
this.scheduleNextAgeTick();
|
|
173
|
+
}, next - now);
|
|
174
|
+
this.clock.unref?.(this.ageTimer);
|
|
175
|
+
}
|
|
176
|
+
|
|
139
177
|
private clearAgeTimer(): void {
|
|
140
178
|
if (!this.ageTimer) return;
|
|
141
|
-
this.clock.
|
|
179
|
+
if (this.ageTimerIsTimeout) this.clock.clearTimeout(this.ageTimer);
|
|
180
|
+
else this.clock.clearInterval(this.ageTimer);
|
|
142
181
|
this.ageTimer = null;
|
|
182
|
+
this.ageTimerIsTimeout = false;
|
|
143
183
|
}
|
|
144
184
|
|
|
145
185
|
private clearWidget(ui: FleetUi): void {
|
|
@@ -161,9 +161,11 @@ function wrapSession(
|
|
|
161
161
|
return {
|
|
162
162
|
...(extras.warning !== undefined ? { warning: extras.warning } : {}),
|
|
163
163
|
...(extras.resolvedModel !== undefined ? { resolvedModel: extras.resolvedModel } : {}),
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
164
|
+
// pi ≥0.99 returns QueuedInputDisposition ("handled" | "queued") from
|
|
165
|
+
// input methods; the adapter contract stays Promise<void>.
|
|
166
|
+
prompt: async (text) => { await session.prompt(text); },
|
|
167
|
+
steer: async (text) => { await session.steer(text); },
|
|
168
|
+
followUp: async (text) => { await session.followUp(text); },
|
|
167
169
|
abort: () => session.abort(),
|
|
168
170
|
waitForIdle: () => session.waitForIdle(),
|
|
169
171
|
getLastAssistantText: () => session.getLastAssistantText(),
|
package/src/tui/tasks-command.ts
CHANGED
|
@@ -10,13 +10,12 @@ import type { ManagerClient, TaskRecord } from "../manager-client";
|
|
|
10
10
|
import { taskOutputPath } from "../config";
|
|
11
11
|
import { formatConversation } from "../subagent/conversation";
|
|
12
12
|
import type { SubagentRegistry } from "../subagent/registry";
|
|
13
|
-
import { formatAge, type WorkIndex, type WorkItem } from "../work-index";
|
|
13
|
+
import { formatAge, nextAgeBoundary, type WorkIndex, type WorkItem } from "../work-index";
|
|
14
14
|
import { notifyPlainFallback, showScrollDetail } from "./scroll-detail-view";
|
|
15
15
|
import { readTaskFileTailCached, stderrPathFor } from "./task-output-paths";
|
|
16
16
|
import { fitLines, loadPiTui, truncateToWidth } from "./pi-tui-load";
|
|
17
17
|
import { statusGlyph } from "./tool-component";
|
|
18
18
|
|
|
19
|
-
const AGE_TICK_MS = 1000;
|
|
20
19
|
|
|
21
20
|
function isLive(item: WorkItem): boolean {
|
|
22
21
|
return item.status === "pending" || item.status === "running";
|
|
@@ -261,9 +260,36 @@ async function showTaskList(
|
|
|
261
260
|
let loadingAll = false;
|
|
262
261
|
let confirmId: string | undefined;
|
|
263
262
|
let hint = "";
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
263
|
+
// formatAge rounds (now - startedAt) to the nearest second, so each
|
|
264
|
+
// live row's visible age flips on half-second boundaries relative to
|
|
265
|
+
// its own startedAt. Schedule the earliest pending flip and
|
|
266
|
+
// re-evaluate after every tick and every index change.
|
|
267
|
+
let ageTimer: unknown = null;
|
|
268
|
+
const clearAgeTimer = () => {
|
|
269
|
+
if (ageTimer === null) return;
|
|
270
|
+
clock.clearTimeout(ageTimer);
|
|
271
|
+
ageTimer = null;
|
|
272
|
+
};
|
|
273
|
+
const scheduleAgeTick = () => {
|
|
274
|
+
clearAgeTimer();
|
|
275
|
+
const now = clock.now();
|
|
276
|
+
let next = Infinity;
|
|
277
|
+
for (const item of visibleItems()) {
|
|
278
|
+
if (!isLive(item)) continue;
|
|
279
|
+
next = Math.min(next, nextAgeBoundary(now, item.startedAt));
|
|
280
|
+
}
|
|
281
|
+
if (!Number.isFinite(next)) return;
|
|
282
|
+
ageTimer = clock.setTimeout(() => {
|
|
283
|
+
ageTimer = null;
|
|
284
|
+
tui.requestRender();
|
|
285
|
+
scheduleAgeTick();
|
|
286
|
+
}, next - now);
|
|
287
|
+
clock.unref?.(ageTimer);
|
|
288
|
+
};
|
|
289
|
+
const unsub = subscribe(() => {
|
|
290
|
+
tui.requestRender();
|
|
291
|
+
scheduleAgeTick();
|
|
292
|
+
});
|
|
267
293
|
const border = new DynamicBorder((text) => theme.fg("border", text));
|
|
268
294
|
const pageSize = () => Math.max(1, (tui.terminal?.rows ?? 24) - 8);
|
|
269
295
|
const matches = (data: string, id: "tui.select.up" | "tui.select.down" | "tui.select.confirm" | "tui.select.cancel") => {
|
|
@@ -273,6 +299,7 @@ async function showTaskList(
|
|
|
273
299
|
scope === "all" ? mergeItems(getItems(), allItems) : getItems(),
|
|
274
300
|
filter,
|
|
275
301
|
);
|
|
302
|
+
scheduleAgeTick();
|
|
276
303
|
const choose = (id: string | undefined) => {
|
|
277
304
|
selectedId = id;
|
|
278
305
|
hint = "";
|
|
@@ -394,7 +421,10 @@ async function showTaskList(
|
|
|
394
421
|
tui.requestRender();
|
|
395
422
|
}
|
|
396
423
|
},
|
|
397
|
-
dispose() {
|
|
424
|
+
dispose() {
|
|
425
|
+
clearAgeTimer();
|
|
426
|
+
unsub();
|
|
427
|
+
},
|
|
398
428
|
};
|
|
399
429
|
},
|
|
400
430
|
// Bottom sheet over the editor, where the user typed /tasks; the list is
|
package/src/work-index.ts
CHANGED
|
@@ -180,3 +180,19 @@ export function formatAge(startedAt: number, endedAt: number | undefined, now: n
|
|
|
180
180
|
if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m${Math.round((ms % 60_000) / 1000)}s`;
|
|
181
181
|
return `${Math.floor(ms / 3_600_000)}h${Math.floor((ms % 3_600_000) / 60_000)}m`;
|
|
182
182
|
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Next time the age displayed by {@link formatAge} for an item started at
|
|
186
|
+
* `startedAt` changes, given at most one render per second.
|
|
187
|
+
*
|
|
188
|
+
* Below 1s formatAge shows raw milliseconds, so a sub-second item's first
|
|
189
|
+
* scheduled update is the transition to "1s" at the 1s mark. At 1s and above
|
|
190
|
+
* the value rounds to the nearest second and flips on half-second boundaries
|
|
191
|
+
* relative to `startedAt`.
|
|
192
|
+
*/
|
|
193
|
+
export function nextAgeBoundary(now: number, startedAt: number): number {
|
|
194
|
+
const age = now - startedAt;
|
|
195
|
+
if (age < 1000) return now + (1000 - age);
|
|
196
|
+
const offset = age % 1000;
|
|
197
|
+
return now + (offset < 500 ? 500 - offset : 1500 - offset);
|
|
198
|
+
}
|