one-code-extension 0.5.0 → 0.6.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/README.md +1 -1
- package/docs/guide/differences-from-claude-code.md +9 -6
- package/docs/guide/installation.md +3 -4
- package/docs/guide/troubleshooting.md +1 -1
- package/extensions/auto-mode/denials.ts +3 -2
- package/extensions/background/cron.ts +127 -0
- package/extensions/background/index.ts +135 -47
- package/extensions/background/loop-fire.ts +39 -2
- package/extensions/background/switch-guard.ts +10 -6
- package/extensions/background/wakeup.ts +21 -2
- package/extensions/bash/index.ts +8 -30
- package/extensions/btw/index.ts +32 -18
- package/extensions/cache-marks/index.ts +42 -0
- package/extensions/claude-context/index.ts +157 -172
- package/extensions/compaction/index.ts +11 -8
- package/extensions/context-budget/budget.ts +16 -2
- package/extensions/context-budget/index.ts +15 -14
- package/extensions/context-management/index.ts +11 -28
- package/extensions/effort/index.ts +70 -7
- package/extensions/file-tracker/index.ts +111 -44
- package/extensions/file-tracker/replay.ts +76 -49
- package/extensions/file-tracker/restore.ts +12 -9
- package/extensions/file-tracker/tracker.ts +14 -1
- package/extensions/hooks/index.ts +60 -76
- package/extensions/hooks/prompt-context.ts +59 -0
- package/extensions/hooks/trust.ts +13 -7
- package/extensions/ledger/index.ts +46 -0
- package/extensions/ledger/wire.ts +154 -0
- package/extensions/lib/agent-cron.ts +17 -1
- package/extensions/lib/agent-loader.ts +3 -2
- package/extensions/lib/anthropic-auth.ts +98 -0
- package/extensions/lib/child-extensions.ts +14 -4
- package/extensions/lib/context-facts.ts +75 -56
- package/extensions/lib/covered-messages.ts +23 -0
- package/extensions/lib/deferred.ts +152 -12
- package/extensions/lib/ledger/fold-memo.ts +49 -0
- package/extensions/lib/ledger/fold.ts +265 -0
- package/extensions/lib/ledger/layout.ts +72 -0
- package/extensions/lib/ledger/plan.ts +102 -0
- package/extensions/lib/ledger/records.ts +276 -0
- package/extensions/lib/ledger/state.ts +47 -0
- package/extensions/lib/ledger/validate.ts +141 -0
- package/extensions/lib/ledger/wire.ts +632 -0
- package/extensions/lib/local-command.ts +24 -6
- package/extensions/lib/model-catalog/huggingface.json +5 -1
- package/extensions/lib/model-catalog/models-dev.json +10535 -10299
- package/extensions/lib/model-catalog/openrouter.json +191 -393
- package/extensions/lib/notifications.ts +57 -17
- package/extensions/lib/pi-version.ts +25 -8
- package/extensions/lib/plan-mode-channels.ts +29 -7
- package/extensions/lib/previous-mark.ts +101 -0
- package/extensions/lib/process-state.ts +48 -0
- package/extensions/lib/process-tree.ts +3 -2
- package/extensions/lib/reminders.ts +327 -621
- package/extensions/lib/request-replay.ts +186 -78
- package/extensions/lib/side-call.ts +11 -0
- package/extensions/lib/subagent-handback.ts +0 -1
- package/extensions/lib/tool-additions.ts +2 -22
- package/extensions/lib/turn-budget-layout.ts +18 -2
- package/extensions/lsp/index.ts +6 -8
- package/extensions/mcp/announce.ts +63 -98
- package/extensions/mcp/index.ts +51 -90
- package/extensions/mcp/trust.ts +13 -6
- package/extensions/model-default/index.ts +86 -19
- package/extensions/permissions/index.ts +157 -48
- package/extensions/permissions/modes.ts +5 -0
- package/extensions/plan-mode/index.ts +142 -45
- package/extensions/plan-mode/run-state.ts +26 -0
- package/extensions/plugins/index.ts +4 -1
- package/extensions/powershell/index.ts +2 -0
- package/extensions/recap/index.ts +11 -14
- package/extensions/search-tools/index.ts +2 -0
- package/extensions/skill/announce.ts +30 -0
- package/extensions/skill/index.ts +65 -48
- package/extensions/skill/invoke.ts +0 -67
- package/extensions/subagents/agents.ts +30 -8
- package/extensions/subagents/fork-cache.ts +9 -3
- package/extensions/subagents/index.ts +180 -107
- package/extensions/subagents/outcome.ts +3 -0
- package/extensions/subagents/parent-mode.ts +50 -0
- package/extensions/subagents/pending-claim.ts +18 -1
- package/extensions/subagents/runner.ts +29 -2
- package/extensions/subagents/runs.ts +5 -0
- package/extensions/system-prompt/index.ts +40 -6
- package/extensions/system-prompt/template.ts +10 -1
- package/extensions/system-reminder/index.ts +174 -202
- package/extensions/tool-search/index.ts +241 -167
- package/extensions/workflow/agent-session.ts +4 -1
- package/extensions/workflow/index.ts +16 -4
- package/extensions/workflow/run-manager.ts +4 -0
- package/extensions/worktree/index.ts +50 -14
- package/package.json +5 -2
- package/extensions/lib/context-stack.ts +0 -81
- package/extensions/subagents/announce.ts +0 -69
- package/extensions/tool-search/announce.ts +0 -58
package/README.md
CHANGED
|
@@ -83,7 +83,7 @@ The [`@one-ai/one-code`](https://www.npmjs.com/package/@one-ai/one-code)
|
|
|
83
83
|
app package bundles a pinned version of pi and provides the `onecode` command.
|
|
84
84
|
The [`one-code-extension`](https://www.npmjs.com/package/one-code-extension)
|
|
85
85
|
package uses your existing pi installation and is tested against pi versions
|
|
86
|
-
0.
|
|
86
|
+
1.0.0 through 1.4. For the extension package, use `pi` in place of `onecode`.
|
|
87
87
|
|
|
88
88
|
The app opens in a full-screen terminal interface by default. See the
|
|
89
89
|
[installation guide](docs/guide/installation.md) for display settings and more
|
|
@@ -102,12 +102,15 @@ from user and managed scope. `includeCoAuthoredBy`, `apiKeyHelper`, `forceLoginM
|
|
|
102
102
|
`/doctor report` to see, per file, which keys were used, ignored, or
|
|
103
103
|
refused.
|
|
104
104
|
|
|
105
|
-
## Prompt layout
|
|
106
|
-
|
|
107
|
-
Claude Code delivers part of its reminder stack
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
105
|
+
## Prompt layout
|
|
106
|
+
|
|
107
|
+
Claude Code delivers part of its reminder stack (the environment, the tool
|
|
108
|
+
and agent listings, the date) to Opus and Sonnet as a system-role message
|
|
109
|
+
after the first user message. One Code does the same on every model that
|
|
110
|
+
pi's model catalog marks as taking a system message at that spot, on
|
|
111
|
+
Anthropic and on OpenAI. Every other model, Haiku 4.5 included, gets the
|
|
112
|
+
same text inside the first user message, the layout Claude Code uses for
|
|
113
|
+
Haiku. The content is the same either way; only the placement differs.
|
|
111
114
|
|
|
112
115
|
## Platform support
|
|
113
116
|
|
|
@@ -102,10 +102,9 @@ pi install npm:one-code-extension
|
|
|
102
102
|
```
|
|
103
103
|
|
|
104
104
|
Use `pi` in place of `onecode` for every command in this guide. The
|
|
105
|
-
extension is tested against pi 0.
|
|
106
|
-
your pi is outside that range. It doesn't load on a pi older than 0.
|
|
107
|
-
update pi first with `pi update`. pi
|
|
108
|
-
on an older pi it's opt-in. See
|
|
105
|
+
extension is tested against pi 1.0.0 through 1.4 and warns at startup when
|
|
106
|
+
your pi is outside that range. It doesn't load on a pi older than 1.0.0;
|
|
107
|
+
update pi first with `pi update`. pi starts in full-screen mode; see
|
|
109
108
|
[Full-screen mode and themes](configuration.md#full-screen-mode-and-themes).
|
|
110
109
|
|
|
111
110
|
pi 0.99 and later ship their own tool search and MCP as built-in
|
|
@@ -39,7 +39,7 @@ pins pi itself.
|
|
|
39
39
|
**`Failed to load extension` on startup.** The extension package and the
|
|
40
40
|
pi it runs on are out of step. On the bundled app, reinstall it. On your own
|
|
41
41
|
pi, run `pi update npm:one-code-extension`, and update pi itself with
|
|
42
|
-
`pi update` if it's older than 0.
|
|
42
|
+
`pi update` if it's older than 1.0.0.
|
|
43
43
|
|
|
44
44
|
**The stock pi first-run wizard didn't appear.** The bundled app seeds
|
|
45
45
|
pi's settings with the One Code theme, quiet startup, and full-screen mode
|
|
@@ -15,8 +15,9 @@
|
|
|
15
15
|
* floor are checked before any grant (working-docs/decisions/auto-mode.md,
|
|
16
16
|
* "Approving a denied call").
|
|
17
17
|
*
|
|
18
|
-
* The store lives in the permissions extension's
|
|
19
|
-
* never persisted, and a new session
|
|
18
|
+
* The store lives in the permissions extension's process state for one
|
|
19
|
+
* session, which a /reload keeps. It is never persisted, and a new session
|
|
20
|
+
* starts empty.
|
|
20
21
|
*/
|
|
21
22
|
|
|
22
23
|
/** Claude Code's `MAX_DENIALS`. */
|
|
@@ -377,6 +377,133 @@ export class CronStore {
|
|
|
377
377
|
clear(): void {
|
|
378
378
|
this.jobs.clear();
|
|
379
379
|
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Put back a job the branch recorded (`scheduledJobsOnBranch`), keeping its
|
|
383
|
+
* id: a /reload re-arms the session's jobs. A recurring job past its age is
|
|
384
|
+
* not restored; a fire missed during the reload comes at once.
|
|
385
|
+
*/
|
|
386
|
+
restore(job: RecordedJob, now: number): boolean {
|
|
387
|
+
const fields = parseCron(job.cron);
|
|
388
|
+
if (!fields || this.jobs.has(job.id)) return false;
|
|
389
|
+
if (job.recurring && now - job.createdAt >= this.maxAgeMs) return false;
|
|
390
|
+
const base = { id: job.id, cron: job.cron, recurring: job.recurring, fields };
|
|
391
|
+
// Due after the job's last fire on the branch (or its creation), so one missed during the reload is due now.
|
|
392
|
+
const nextFireAt = job.fireAt ?? this.fireAfter(base, Math.max(job.lastFiredAt ?? 0, job.createdAt));
|
|
393
|
+
if (nextFireAt === null) return false;
|
|
394
|
+
this.jobs.set(job.id, { ...base, prompt: job.prompt, source: job.source, createdAt: job.createdAt, nextFireAt });
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** A job as the branch records it: what `CronStore.restore` takes. */
|
|
400
|
+
export interface RecordedJob {
|
|
401
|
+
id: string;
|
|
402
|
+
cron: string;
|
|
403
|
+
prompt: string;
|
|
404
|
+
recurring: boolean;
|
|
405
|
+
source: CronSource;
|
|
406
|
+
createdAt: number;
|
|
407
|
+
/** A wakeup's exact fire time. */
|
|
408
|
+
fireAt?: number;
|
|
409
|
+
/** A recurring job's last fire on the branch: the next one is due after it. */
|
|
410
|
+
lastFiredAt?: number;
|
|
411
|
+
/** A wakeup's loop start, and whether the keepalive armed it (DynamicLoop.restoreChain). */
|
|
412
|
+
chainStartedAt?: number;
|
|
413
|
+
keepalive?: boolean;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
type BranchEntry = { type?: string; timestamp?: string; customType?: string; details?: unknown; data?: unknown; message?: unknown };
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* The custom entry a main-session job's fire writes (`{ jobId, final, source }`),
|
|
420
|
+
* whatever way its notification is then delivered: a fire sent as a plain user
|
|
421
|
+
* message (no turn prompted since a /reload) carries no details.
|
|
422
|
+
*/
|
|
423
|
+
export const JOB_FIRED_ENTRY = "one-code:job-fired";
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* The custom entry the loop's keepalive writes when it arms a wakeup itself
|
|
427
|
+
* (`{ id, prompt, fireAt, startedAt }`): no `schedule_wakeup` result records that one.
|
|
428
|
+
*/
|
|
429
|
+
export const WAKEUP_ARMED_ENTRY = "one-code:wakeup-armed";
|
|
430
|
+
type Message = { role?: string; toolName?: string; toolCallId?: string; isError?: boolean; details?: unknown; content?: unknown; timestamp?: number };
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* The main session's scheduled jobs a branch still holds: cron_create and
|
|
434
|
+
* schedule_wakeup results (the prompt read from their calls), minus
|
|
435
|
+
* cron_delete results, a wakeup stop, and final fires. A wakeup supersedes the
|
|
436
|
+
* one before it, as `DynamicLoop.schedule` does. Pure.
|
|
437
|
+
*/
|
|
438
|
+
export function scheduledJobsOnBranch(branch: readonly BranchEntry[]): RecordedJob[] {
|
|
439
|
+
const calls = new Map<string, Record<string, unknown>>();
|
|
440
|
+
const jobs = new Map<string, RecordedJob>();
|
|
441
|
+
const dropWakeups = () => {
|
|
442
|
+
for (const [id, job] of jobs) if (job.source === "wakeup") jobs.delete(id);
|
|
443
|
+
};
|
|
444
|
+
for (const entry of branch) {
|
|
445
|
+
if (entry.type === "custom" && entry.customType === WAKEUP_ARMED_ENTRY) {
|
|
446
|
+
const armed = entry.data as { id?: unknown; prompt?: unknown; fireAt?: unknown; startedAt?: unknown } | undefined;
|
|
447
|
+
if (typeof armed?.id !== "string" || typeof armed.prompt !== "string" || typeof armed.fireAt !== "number") continue;
|
|
448
|
+
dropWakeups();
|
|
449
|
+
const target = new Date(armed.fireAt);
|
|
450
|
+
const createdAt = Date.parse(entry.timestamp ?? "") || 0;
|
|
451
|
+
jobs.set(armed.id, {
|
|
452
|
+
id: armed.id,
|
|
453
|
+
cron: `${target.getMinutes()} ${target.getHours()} * * *`,
|
|
454
|
+
prompt: armed.prompt,
|
|
455
|
+
recurring: false,
|
|
456
|
+
source: "wakeup",
|
|
457
|
+
createdAt,
|
|
458
|
+
fireAt: armed.fireAt,
|
|
459
|
+
chainStartedAt: typeof armed.startedAt === "number" ? armed.startedAt : createdAt,
|
|
460
|
+
keepalive: true,
|
|
461
|
+
});
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
const fired = entry.type === "custom" && entry.customType === JOB_FIRED_ENTRY;
|
|
465
|
+
if (fired || (entry.type === "custom_message" && (entry.customType === "wakeup" || entry.customType === "cron"))) {
|
|
466
|
+
const details = (fired ? entry.data : entry.details) as { jobId?: unknown; final?: unknown; source?: unknown } | undefined;
|
|
467
|
+
const wakeup = fired ? details?.source === "wakeup" : entry.customType === "wakeup";
|
|
468
|
+
if (details?.final !== true) {
|
|
469
|
+
const job = typeof details?.jobId === "string" ? jobs.get(details.jobId) : undefined;
|
|
470
|
+
const at = Date.parse(entry.timestamp ?? "");
|
|
471
|
+
if (job && !Number.isNaN(at)) job.lastFiredAt = at;
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
if (wakeup) dropWakeups();
|
|
475
|
+
else if (typeof details.jobId === "string") jobs.delete(details.jobId);
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
if (entry.type !== "message") continue;
|
|
479
|
+
const message = entry.message as Message | undefined;
|
|
480
|
+
if (message?.role === "assistant" && Array.isArray(message.content)) {
|
|
481
|
+
for (const block of message.content as Array<{ type?: string; id?: string; arguments?: unknown }>) {
|
|
482
|
+
if (block?.type === "toolCall" && typeof block.id === "string" && block.arguments && typeof block.arguments === "object") calls.set(block.id, block.arguments as Record<string, unknown>);
|
|
483
|
+
}
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
if (message?.role !== "toolResult" || message.isError || !message.toolCallId) continue;
|
|
487
|
+
const args = calls.get(message.toolCallId) ?? {};
|
|
488
|
+
const details = (message.details ?? {}) as Record<string, unknown>;
|
|
489
|
+
const at = message.timestamp ?? (Date.parse(entry.timestamp ?? "") || 0);
|
|
490
|
+
if (message.toolName === "cron_create" && typeof details.jobId === "string" && typeof details.cron === "string" && typeof args.prompt === "string") {
|
|
491
|
+
jobs.set(details.jobId, { id: details.jobId, cron: details.cron, prompt: args.prompt, recurring: details.recurring !== false, source: "model", createdAt: at });
|
|
492
|
+
} else if (message.toolName === "cron_delete" && typeof details.jobId === "string") {
|
|
493
|
+
jobs.delete(details.jobId);
|
|
494
|
+
} else if (message.toolName === "schedule_wakeup") {
|
|
495
|
+
// A schedule replaces the pending wakeup; one past the loop's age (scheduledFor 0) cancels it.
|
|
496
|
+
if (details.stopped === true || typeof details.scheduledFor === "number") dropWakeups();
|
|
497
|
+
if (typeof details.scheduledFor === "number" && details.scheduledFor > 0 && typeof args.prompt === "string") {
|
|
498
|
+
const target = new Date(details.scheduledFor);
|
|
499
|
+
// The store's own id, so a cron_delete of the wakeup removes it here too.
|
|
500
|
+
const id = typeof details.jobId === "string" ? details.jobId : `w-${message.toolCallId}`;
|
|
501
|
+
const chainStartedAt = typeof details.startedAt === "number" ? details.startedAt : at;
|
|
502
|
+
jobs.set(id, { id, cron: `${target.getMinutes()} ${target.getHours()} * * *`, prompt: args.prompt, recurring: false, source: "wakeup", createdAt: at, fireAt: details.scheduledFor, chainStartedAt });
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return [...jobs.values()];
|
|
380
507
|
}
|
|
381
508
|
|
|
382
509
|
function publicJob(job: CronJob & { fields?: CronFields }): CronJob {
|
|
@@ -44,7 +44,16 @@ import {
|
|
|
44
44
|
SCHEDULE_WAKEUP_PARAMS,
|
|
45
45
|
WAKEUP_ERRORS,
|
|
46
46
|
} from "./wakeup.ts";
|
|
47
|
-
import {
|
|
47
|
+
import {
|
|
48
|
+
deliveryFromState,
|
|
49
|
+
isAutonomousSentinel,
|
|
50
|
+
isLoopFileSentinel,
|
|
51
|
+
LOOP_DELIVERY_DETAILS_KEY,
|
|
52
|
+
LOOP_DELIVERY_STATE,
|
|
53
|
+
type LoopDeliveryState,
|
|
54
|
+
readLoopFile,
|
|
55
|
+
resolveLoopFire,
|
|
56
|
+
} from "./loop-fire.ts";
|
|
48
57
|
import { autonomousPreamble, loopSkillPrompt } from "./loop-skill.ts";
|
|
49
58
|
import {
|
|
50
59
|
CRON_CREATE_DESCRIPTION,
|
|
@@ -62,6 +71,9 @@ import {
|
|
|
62
71
|
formatDeleteResult,
|
|
63
72
|
formatJobList,
|
|
64
73
|
formatUnknownJob,
|
|
74
|
+
JOB_FIRED_ENTRY,
|
|
75
|
+
scheduledJobsOnBranch,
|
|
76
|
+
WAKEUP_ARMED_ENTRY,
|
|
65
77
|
} from "./cron.ts";
|
|
66
78
|
import {
|
|
67
79
|
createTaskNotifier,
|
|
@@ -88,7 +100,7 @@ import {
|
|
|
88
100
|
import { registerLocalCommand } from "../lib/local-command.ts";
|
|
89
101
|
import { applyNoopFolds, decideFold, type FoldDetails, type FoldEntry, foldSuffix } from "./noop-fold.ts";
|
|
90
102
|
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
91
|
-
import { AGENT_CRON_CHANNEL, AGENT_CRON_FIRE_CHANNEL, type AgentCronFire, type AgentCronRequest, formatNotOwner } from "../lib/agent-cron.ts";
|
|
103
|
+
import { AGENT_CRON_CHANNEL, AGENT_CRON_FIRE_CHANNEL, type AgentCronFire, type AgentCronRequest, type FireConversation, formatNotOwner } from "../lib/agent-cron.ts";
|
|
92
104
|
import { SKILL_BODY_CHANNEL, type SkillBodyQuery, SLASH_EXPAND_CHANNEL, type SlashExpandQuery } from "../lib/skill-body.ts";
|
|
93
105
|
import { BUNDLED_SKILLS_DIR } from "../lib/skill-scan.ts";
|
|
94
106
|
import { createWriteStream, mkdirSync, type WriteStream } from "node:fs";
|
|
@@ -97,6 +109,10 @@ import { StringDecoder } from "node:string_decoder";
|
|
|
97
109
|
import { SESSION_WORK_CHANNEL, sessionBackgroundTask, sessionCron, type SessionWorkQuery } from "../lib/session-work.ts";
|
|
98
110
|
import { WORKTREE_CHANNEL, type WorktreeLocation } from "../lib/worktree-channel.ts";
|
|
99
111
|
import { queueNotice } from "../lib/notices.ts";
|
|
112
|
+
import { makeRecord, RECORD } from "../lib/ledger/records.ts";
|
|
113
|
+
import { recordState, stateSinceCompaction } from "../lib/ledger/state.ts";
|
|
114
|
+
import { processState } from "../lib/process-state.ts";
|
|
115
|
+
import { extensionVersion } from "../lib/package-version.ts";
|
|
100
116
|
|
|
101
117
|
/** Claude Code's `task_output` limit (`TASK_MAX_OUTPUT_DEFAULT`). */
|
|
102
118
|
const TASK_OUTPUT_MAX_CHARS = 32_000;
|
|
@@ -115,15 +131,16 @@ function tail(text: string, cap: number): string {
|
|
|
115
131
|
}
|
|
116
132
|
|
|
117
133
|
/**
|
|
118
|
-
* `/clear`, `/new` and `/
|
|
119
|
-
* they belong to (the notifier that would report them is inert by
|
|
120
|
-
* the new
|
|
121
|
-
* the old session's UI is torn down right after `session_shutdown`,
|
|
122
|
-
* notice printed there is lost
|
|
123
|
-
*
|
|
124
|
-
*
|
|
134
|
+
* `/clear`, `/new`, `/resume` and `/reload` stop every background task with
|
|
135
|
+
* the session they belong to (the notifier that would report them is inert by
|
|
136
|
+
* then, and the new instance has no handle on them). Say so, once, in the NEW
|
|
137
|
+
* session: the old session's UI is torn down right after `session_shutdown`,
|
|
138
|
+
* so a notice printed there is lost (LIFECYCLE-REVIEW-2026-09-06 L3). The
|
|
139
|
+
* notice is process state (lib/process-state.ts): module scope would lose it
|
|
140
|
+
* wherever pi evaluates this module again, at `/reload` and at a `/resume`
|
|
141
|
+
* into another directory (lifecycle grid R3-L13).
|
|
125
142
|
*/
|
|
126
|
-
|
|
143
|
+
const shutdownNotice = () => processState<{ text?: string }>("background:shutdown-notice", () => ({}));
|
|
127
144
|
|
|
128
145
|
/**
|
|
129
146
|
* `<sessionDir>/monitor/<taskId>/output.log`, beside background shells'
|
|
@@ -188,16 +205,6 @@ export default function backgroundExtension(pi: ExtensionAPI) {
|
|
|
188
205
|
let lastCtx: ExtensionContext | undefined;
|
|
189
206
|
const cron = new CronStore();
|
|
190
207
|
const dynamicLoop = new DynamicLoop(cron);
|
|
191
|
-
// What the no-prompt /loop sentinels already delivered, per recipient (the
|
|
192
|
-
// main session, or a subagent by task id): each conversation has its own
|
|
193
|
-
// record. The main session's compaction resets its own (loop-fire.ts).
|
|
194
|
-
const MAIN_OWNER = "main";
|
|
195
|
-
const loopDelivery = new Map<string, LoopDeliveryState>();
|
|
196
|
-
const deliveryFor = (owner: string): LoopDeliveryState => {
|
|
197
|
-
let state = loopDelivery.get(owner);
|
|
198
|
-
if (!state) loopDelivery.set(owner, (state = freshDeliveryState()));
|
|
199
|
-
return state;
|
|
200
|
-
};
|
|
201
208
|
let sessionCwd = process.cwd();
|
|
202
209
|
// The main session's worktree while it is in one (enter_worktree), where its fires resolve.
|
|
203
210
|
let worktreeCwd: string | undefined;
|
|
@@ -255,7 +262,7 @@ export default function backgroundExtension(pi: ExtensionAPI) {
|
|
|
255
262
|
const live = liveUiCtx(lastCtx);
|
|
256
263
|
if (!live) return;
|
|
257
264
|
// Every running task and scheduled job, panel-owned ones included: the
|
|
258
|
-
// line is the warning
|
|
265
|
+
// line is the warning of what /clear, /reload or quitting stops (switch-guard.ts).
|
|
259
266
|
const line = workWidgetLine(registry.running().length, cron.list().length);
|
|
260
267
|
live.ui.setWidget("cc-background", line ? [line] : undefined);
|
|
261
268
|
};
|
|
@@ -286,7 +293,52 @@ export default function backgroundExtension(pi: ExtensionAPI) {
|
|
|
286
293
|
|
|
287
294
|
// A monitor's end the model already read through task_output is withdrawn
|
|
288
295
|
// like a shell's (lib/notifications.ts).
|
|
289
|
-
const notify = createTaskNotifier(pi, {
|
|
296
|
+
const notify = createTaskNotifier(pi, {
|
|
297
|
+
withdrawOnDelivery: true,
|
|
298
|
+
// A fire's message reached the conversation: what it told the model is on the branch from here.
|
|
299
|
+
onDelivered: (customType, details) => {
|
|
300
|
+
if (customType !== "cron" && customType !== "wakeup") return;
|
|
301
|
+
const stored = details[LOOP_DELIVERY_DETAILS_KEY];
|
|
302
|
+
if (stored !== undefined) recordDelivery(pi, deliveryFromState(stored));
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* A no-prompt /loop sentinel's text (loop-fire.ts), against what the
|
|
308
|
+
* conversation it lands in was already sent. That record is branch state
|
|
309
|
+
* of that conversation: the main session's, or a subagent's own session,
|
|
310
|
+
* since each has its own history and compacts on its own. Read since the
|
|
311
|
+
* last compaction, so the first fire after one resends the instructions,
|
|
312
|
+
* as Claude Code does. `delivery` is the record this text makes true, set
|
|
313
|
+
* only when it changes (lifecycle grid R3-L12); the caller writes it when
|
|
314
|
+
* the text reaches the conversation.
|
|
315
|
+
*/
|
|
316
|
+
const expandSentinel = (prompt: string, cwd: string, conversation: FireConversation | undefined): { text: string; delivery?: LoopDeliveryState } => {
|
|
317
|
+
let branch: readonly unknown[] = [];
|
|
318
|
+
try {
|
|
319
|
+
branch = conversation?.getBranch() ?? [];
|
|
320
|
+
} catch {
|
|
321
|
+
// A torn-down session reads as one that was sent nothing.
|
|
322
|
+
}
|
|
323
|
+
const before = deliveryFromState(stateSinceCompaction(branch, LOOP_DELIVERY_STATE));
|
|
324
|
+
const state = { ...before };
|
|
325
|
+
const text = resolveLoopFire(state, prompt, cwd, autonomousPreamble());
|
|
326
|
+
const changed = state.autonomousPreambleDelivered !== before.autonomousPreambleDelivered || state.lastLoopFileDelivered !== before.lastLoopFileDelivered;
|
|
327
|
+
return changed ? { text, delivery: state } : { text };
|
|
328
|
+
};
|
|
329
|
+
/** Record what a delivered fire told its conversation; a torn-down one has nothing to record into. */
|
|
330
|
+
const recordDelivery = (conversation: Pick<FireConversation, "appendEntry">, delivery: LoopDeliveryState) => {
|
|
331
|
+
try {
|
|
332
|
+
recordState(conversation, LOOP_DELIVERY_STATE, delivery);
|
|
333
|
+
} catch {
|
|
334
|
+
// Same: nothing to record into.
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
/** The main session as a fire's conversation, while it is live. */
|
|
338
|
+
const mainConversation = (): FireConversation | undefined => {
|
|
339
|
+
const ctx = lastCtx;
|
|
340
|
+
return ctx ? { getBranch: () => ctx.sessionManager.getBranch(), appendEntry: (customType, data) => pi.appendEntry(customType, data) } : undefined;
|
|
341
|
+
};
|
|
290
342
|
|
|
291
343
|
/**
|
|
292
344
|
* The text a fired prompt delivers, as Claude Code's queue would run it: a
|
|
@@ -295,14 +347,14 @@ export default function backgroundExtension(pi: ExtensionAPI) {
|
|
|
295
347
|
* prompt verbatim. Both resolve in the job owner's directory: a subagent's
|
|
296
348
|
* own, or the main session's worktree while it is in one.
|
|
297
349
|
*/
|
|
298
|
-
const fireText = (job: CronJob): string => {
|
|
350
|
+
const fireText = (job: CronJob, conversation: FireConversation | undefined): { text: string; delivery?: LoopDeliveryState } => {
|
|
299
351
|
const { prompt } = job;
|
|
300
352
|
const cwd = job.cwd ?? worktreeCwd ?? sessionCwd;
|
|
301
|
-
|
|
302
|
-
if (
|
|
353
|
+
if (isAutonomousSentinel(prompt) || isLoopFileSentinel(prompt)) return expandSentinel(prompt, cwd, conversation);
|
|
354
|
+
if (!prompt.startsWith("/")) return { text: prompt };
|
|
303
355
|
const query: SlashExpandQuery = { text: prompt, cwd };
|
|
304
356
|
pi.events.emit(SLASH_EXPAND_CHANNEL, query);
|
|
305
|
-
return query.expanded ?? prompt;
|
|
357
|
+
return { text: query.expanded ?? prompt };
|
|
306
358
|
};
|
|
307
359
|
/** Claude Code's no-op fold for the wakeup firing now (noop-fold.ts), read off the session branch. */
|
|
308
360
|
const foldDetails = (): FoldDetails => {
|
|
@@ -319,18 +371,32 @@ export default function backgroundExtension(pi: ExtensionAPI) {
|
|
|
319
371
|
const { job, final } = fire;
|
|
320
372
|
if (job.agentId !== undefined) {
|
|
321
373
|
// A subagent's job goes to that agent, or is dropped once it has ended.
|
|
322
|
-
|
|
374
|
+
// The agent's resident sends the text as soon as it asks for it, so the record goes with the text.
|
|
375
|
+
const text = (conversation: FireConversation | undefined) => {
|
|
376
|
+
const fired = fireText(job, conversation);
|
|
377
|
+
if (fired.delivery && conversation) recordDelivery(conversation, fired.delivery);
|
|
378
|
+
return fired.text;
|
|
379
|
+
};
|
|
380
|
+
const delivery: AgentCronFire = { agentId: job.agentId, jobId: job.id, text };
|
|
323
381
|
pi.events.emit(AGENT_CRON_FIRE_CHANNEL, delivery);
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
cron.delete(job.id);
|
|
327
|
-
loopDelivery.delete(job.agentId);
|
|
328
|
-
}
|
|
382
|
+
// The agent has ended: its job goes with it.
|
|
383
|
+
if (!delivery.delivered) cron.delete(job.id);
|
|
329
384
|
return;
|
|
330
385
|
}
|
|
331
386
|
const fold = job.source === "wakeup" ? foldDetails() : {};
|
|
387
|
+
// A fold removes the tick's messages from the model's context: above the
|
|
388
|
+
// append point, so the next request opens an epoch (extensions/ledger)
|
|
389
|
+
// and renders the folded context whole, once.
|
|
390
|
+
if (fold.noOpStreak !== undefined) pi.appendEntry(RECORD.epoch, makeRecord("epoch", extensionVersion(), { cause: "fold" }));
|
|
332
391
|
if (job.source === "wakeup") dynamicLoop.inFlight = job.prompt;
|
|
333
|
-
|
|
392
|
+
// Recorded whatever way the notification goes out, so a /reload knows the fire happened.
|
|
393
|
+
pi.appendEntry(JOB_FIRED_ENTRY, { jobId: job.id, final, source: job.source });
|
|
394
|
+
// The notifier may hold the message (a coalescing window, an interrupt) and
|
|
395
|
+
// a /reload drops what it holds, so the loop's record rides the message and
|
|
396
|
+
// is written when it is delivered (the notifier's onDelivered above).
|
|
397
|
+
const { text, delivery } = fireText(job, mainConversation());
|
|
398
|
+
const details = { jobId: job.id, cron: job.cron, source: job.source, final, ...fold, ...(delivery ? { [LOOP_DELIVERY_DETAILS_KEY]: delivery } : {}) };
|
|
399
|
+
notify(job.source === "wakeup" ? "wakeup" : "cron", text, details);
|
|
334
400
|
};
|
|
335
401
|
/** Fire every due job (only while idle), then arm the one timer to the next fire. */
|
|
336
402
|
const runCron = () => {
|
|
@@ -869,12 +935,34 @@ export default function backgroundExtension(pi: ExtensionAPI) {
|
|
|
869
935
|
query.body = loopSkillPrompt(query.args, readLoopFile(query.cwd));
|
|
870
936
|
});
|
|
871
937
|
|
|
872
|
-
|
|
938
|
+
/** Arm the branch's scheduled jobs, with each wakeup's loop age and keepalive spend. */
|
|
939
|
+
const armFromBranch = (ctx: ExtensionContext) => {
|
|
940
|
+
const now = Date.now();
|
|
941
|
+
for (const job of scheduledJobsOnBranch(ctx.sessionManager.getBranch() as never)) {
|
|
942
|
+
if (!cron.restore(job, now) || job.source !== "wakeup") continue;
|
|
943
|
+
dynamicLoop.restoreChain(job.prompt, job.chainStartedAt ?? job.createdAt, job.fireAt ?? now, job.keepalive === true);
|
|
944
|
+
}
|
|
945
|
+
runCron();
|
|
946
|
+
};
|
|
947
|
+
// The jobs follow the branch: a /tree switch ends the abandoned branch's and
|
|
948
|
+
// arms the selected one's (a subagent's own jobs stay with the agent).
|
|
949
|
+
pi.on("session_tree", (_event, ctx) => {
|
|
950
|
+
lastCtx = ctx;
|
|
951
|
+
cron.deleteWhere((job) => job.agentId === undefined);
|
|
952
|
+
dynamicLoop.clear();
|
|
953
|
+
armFromBranch(ctx);
|
|
954
|
+
});
|
|
955
|
+
pi.on("session_start", (event, ctx) => {
|
|
873
956
|
lastCtx = ctx;
|
|
874
957
|
sessionCwd = ctx.cwd;
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
958
|
+
// A /reload keeps the process and the conversation, so the session's
|
|
959
|
+
// scheduled jobs come back from the branch (design.md section 13); a new
|
|
960
|
+
// process ends them, as Claude Code's session-only jobs end.
|
|
961
|
+
if (event.reason === "reload") armFromBranch(ctx);
|
|
962
|
+
const pending = shutdownNotice();
|
|
963
|
+
if (pending.text) {
|
|
964
|
+
const notice = pending.text;
|
|
965
|
+
pending.text = undefined;
|
|
878
966
|
if (ctx.hasUI) queueNotice(pi.events, ctx, "info", notice);
|
|
879
967
|
}
|
|
880
968
|
});
|
|
@@ -883,8 +971,12 @@ export default function backgroundExtension(pi: ExtensionAPI) {
|
|
|
883
971
|
});
|
|
884
972
|
pi.on("agent_settled", () => {
|
|
885
973
|
agentBusy = false;
|
|
886
|
-
// Claude Code's keepalive: a wakeup's turn that scheduled no next one gets
|
|
887
|
-
|
|
974
|
+
// Claude Code's keepalive: a wakeup's turn that scheduled no next one gets
|
|
975
|
+
// one fallback, recorded so a /reload re-arms it (no tool result does).
|
|
976
|
+
if (dynamicLoop.settle(Date.now()) === "armed") {
|
|
977
|
+
const armed = cron.list().find((job) => job.source === "wakeup");
|
|
978
|
+
if (armed) pi.appendEntry(WAKEUP_ARMED_ENTRY, { id: armed.id, prompt: armed.prompt, fireAt: armed.nextFireAt, startedAt: dynamicLoop.chainStart(armed.prompt) });
|
|
979
|
+
}
|
|
888
980
|
runCron();
|
|
889
981
|
});
|
|
890
982
|
// Folded no-op ticks leave the model's context (noop-fold.ts); the note stands in for them.
|
|
@@ -892,11 +984,6 @@ export default function backgroundExtension(pi: ExtensionAPI) {
|
|
|
892
984
|
const messages = applyNoopFolds(event.messages as Array<AgentMessage & { role: string }>, (text, timestamp) => ({ role: "user", content: [{ type: "text", text }], timestamp }) as AgentMessage & { role: string });
|
|
893
985
|
return messages ? { messages } : undefined;
|
|
894
986
|
});
|
|
895
|
-
// The first fire after a compaction resends the full loop instructions, as in Claude Code.
|
|
896
|
-
pi.on("session_compact", () => {
|
|
897
|
-
loopDelivery.delete(MAIN_OWNER);
|
|
898
|
-
});
|
|
899
|
-
|
|
900
987
|
// A switch stops everything below, so ask first. pi can cancel a switch but
|
|
901
988
|
// not a quit; the widget line covers quitting (switch-guard.ts).
|
|
902
989
|
const confirmSwitch = async (ctx: ExtensionContext, action: string): Promise<{ cancel: true } | undefined> => {
|
|
@@ -918,10 +1005,11 @@ export default function backgroundExtension(pi: ExtensionAPI) {
|
|
|
918
1005
|
pi.on("session_shutdown", (event) => {
|
|
919
1006
|
// Fires on /clear, /new, /resume and /reload too (findings §8). Everything
|
|
920
1007
|
// running dies with this instance — the replacement instance has no handle
|
|
921
|
-
// on it — and the replacement is told (see
|
|
1008
|
+
// on it — and the replacement is told (see shutdownNotice); a quit
|
|
922
1009
|
// needs no note. A reload keeps the conversation, so its note says so.
|
|
923
1010
|
const running = registry.running();
|
|
924
|
-
|
|
1011
|
+
// A reload re-arms the jobs from the branch: only the tasks stop.
|
|
1012
|
+
const jobs = event.reason === "reload" ? [] : cron.list();
|
|
925
1013
|
if ((running.length > 0 || jobs.length > 0) && event.reason !== "quit") {
|
|
926
1014
|
const when = event.reason === "reload" ? "on reload" : "with the previous session";
|
|
927
1015
|
const notices: string[] = [];
|
|
@@ -933,7 +1021,7 @@ export default function backgroundExtension(pi: ExtensionAPI) {
|
|
|
933
1021
|
const names = jobs.map((j) => `${j.id} (${describeCadence(j.cron)})`).join(", ");
|
|
934
1022
|
notices.push(`Cancelled ${jobs.length} scheduled job${jobs.length === 1 ? "" : "s"} ${when}: ${names}.`);
|
|
935
1023
|
}
|
|
936
|
-
|
|
1024
|
+
shutdownNotice().text = notices.join(" ");
|
|
937
1025
|
}
|
|
938
1026
|
registry.stopAll();
|
|
939
1027
|
cron.clear();
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { projectConfigDir, userConfigDir } from "../lib/config-mode.ts";
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
15
16
|
import { readFileSync } from "node:fs";
|
|
16
17
|
import { homedir } from "node:os";
|
|
17
18
|
import { join } from "node:path";
|
|
@@ -35,7 +36,12 @@ export interface LoopFile {
|
|
|
35
36
|
content: string;
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* What one conversation was already sent; compaction resets it (the first
|
|
41
|
+
* fire after it resends everything). `lastLoopFileDelivered` is the delivered
|
|
42
|
+
* loop.md's fingerprint (`loopFileMark`), not its text, so the record stays
|
|
43
|
+
* small on the branch.
|
|
44
|
+
*/
|
|
39
45
|
export interface LoopDeliveryState {
|
|
40
46
|
autonomousPreambleDelivered: boolean;
|
|
41
47
|
lastLoopFileDelivered: string | null;
|
|
@@ -45,6 +51,37 @@ export function freshDeliveryState(): LoopDeliveryState {
|
|
|
45
51
|
return { autonomousPreambleDelivered: false, lastLoopFileDelivered: null };
|
|
46
52
|
}
|
|
47
53
|
|
|
54
|
+
/**
|
|
55
|
+
* The `one-code:state` key the record lives under, on the branch of the
|
|
56
|
+
* conversation the fires land in (the main session's, or a subagent's own):
|
|
57
|
+
* it follows `/tree`, survives `/reload` and resume, and a compaction resets
|
|
58
|
+
* it (lib/ledger/state.ts `stateSinceCompaction`).
|
|
59
|
+
*/
|
|
60
|
+
export const LOOP_DELIVERY_STATE = "loop-delivery";
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The key a main-session fire's notification details carry its record
|
|
64
|
+
* under. The record is written when that message is delivered (the
|
|
65
|
+
* notifier's `onDelivered`), not when the text is built: the notifier may
|
|
66
|
+
* still hold it, and a `/reload` or `/clear` then drops it unsent.
|
|
67
|
+
*/
|
|
68
|
+
export const LOOP_DELIVERY_DETAILS_KEY = "loopDelivery";
|
|
69
|
+
|
|
70
|
+
/** A stored record read back, or a fresh one when it is missing or malformed. */
|
|
71
|
+
export function deliveryFromState(value: unknown): LoopDeliveryState {
|
|
72
|
+
const stored = value as Partial<LoopDeliveryState> | undefined;
|
|
73
|
+
if (!stored || typeof stored !== "object") return freshDeliveryState();
|
|
74
|
+
return {
|
|
75
|
+
autonomousPreambleDelivered: stored.autonomousPreambleDelivered === true,
|
|
76
|
+
lastLoopFileDelivered: typeof stored.lastLoopFileDelivered === "string" ? stored.lastLoopFileDelivered : null,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** What a delivered loop.md is remembered by: equal texts, equal marks. */
|
|
81
|
+
export function loopFileMark(content: string): string {
|
|
82
|
+
return `sha256:${createHash("sha256").update(content).digest("hex")}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
48
85
|
export function isAutonomousSentinel(prompt: string): boolean {
|
|
49
86
|
return prompt === AUTONOMOUS_LOOP || prompt === AUTONOMOUS_LOOP_DYNAMIC;
|
|
50
87
|
}
|
|
@@ -112,7 +149,7 @@ export function readLoopFile(cwd: string, home = homedir()): LoopFile | null {
|
|
|
112
149
|
return readLoopFileAt(join(projectConfigDir(cwd), "loop.md")) ?? readLoopFileAt(join(userConfigDir(home), "loop.md"));
|
|
113
150
|
}
|
|
114
151
|
|
|
115
|
-
function resolveLoopFile(e: LoopDeliveryState, t: string, o: LoopFile | null, preamble: string): string {let n=t===LOOP_FILE_DYNAMIC_SENTINEL;if(o){let s=n?loopFileDynamicTick():loopFileCronTick();if(e.lastLoopFileDelivered===
|
|
152
|
+
function resolveLoopFile(e: LoopDeliveryState, t: string, o: LoopFile | null, preamble: string): string {let n=t===LOOP_FILE_DYNAMIC_SENTINEL;if(o){let s=n?loopFileDynamicTick():loopFileCronTick(),m=loopFileMark(o.content);if(e.lastLoopFileDelivered===m)return s;return e.lastLoopFileDelivered=m,`# /loop tick \u2014 tasks from ${o.path}
|
|
116
153
|
|
|
117
154
|
The user configured a loop-tasks file. Work through the tasks defined below; these are the instructions for this tick and every subsequent tick (the reminder on later fires refers back to this message).
|
|
118
155
|
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* What a session switch or quit would stop (pure). Running background tasks and
|
|
3
|
-
* scheduled jobs belong to the session: `/clear`, `/new`, `/resume
|
|
4
|
-
* stop them
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
3
|
+
* scheduled jobs belong to the session: `/clear`, `/new`, `/resume`, a fork and
|
|
4
|
+
* quitting stop them. A `/reload` stops the running tasks only: it keeps the
|
|
5
|
+
* conversation, so the scheduled jobs come back from the branch
|
|
6
|
+
* (background/index.ts `armFromBranch`). pi lets an extension cancel a switch
|
|
7
|
+
* (`session_before_switch`, `session_before_fork`), so a switch asks first;
|
|
8
|
+
* reloading and quitting have no such hook, so the standing widget line says
|
|
9
|
+
* it up front.
|
|
8
10
|
*/
|
|
9
11
|
|
|
10
12
|
/** A running task or scheduled job, as the dialog and widget name it. */
|
|
@@ -48,5 +50,7 @@ export function switchWarning(tasks: readonly SessionWork[], jobs: readonly Sess
|
|
|
48
50
|
/** The standing widget line while anything runs, or undefined when nothing does. */
|
|
49
51
|
export function workWidgetLine(tasks: number, jobs: number): string | undefined {
|
|
50
52
|
if (tasks === 0 && jobs === 0) return undefined;
|
|
51
|
-
|
|
53
|
+
// A /reload stops only the running tasks; the jobs re-arm from the branch.
|
|
54
|
+
const stoppedBy = jobs === 0 ? "/clear, /reload or quitting" : tasks === 0 ? "/clear or quitting" : "/clear or quitting, the tasks by /reload too";
|
|
55
|
+
return ` ${counts(tasks, jobs)} · stopped by ${stoppedBy}${tasks > 0 ? " · /tasks to manage" : ""}`;
|
|
52
56
|
}
|
|
@@ -40,6 +40,10 @@ export interface WakeupSchedule {
|
|
|
40
40
|
scheduledFor: number;
|
|
41
41
|
clampedDelaySeconds: number;
|
|
42
42
|
wasClamped: boolean;
|
|
43
|
+
/** The store's job id, so a cron_delete of it is matched when the branch is replayed. */
|
|
44
|
+
jobId?: string;
|
|
45
|
+
/** When this prompt's loop started: its age carries across a /reload. */
|
|
46
|
+
startedAt: number;
|
|
43
47
|
}
|
|
44
48
|
|
|
45
49
|
interface PromptAge {
|
|
@@ -59,6 +63,11 @@ export class DynamicLoop {
|
|
|
59
63
|
private readonly maxAgeMs = RECURRING_MAX_AGE_MS,
|
|
60
64
|
) {}
|
|
61
65
|
|
|
66
|
+
/** When the loop for `prompt` started, if it is running. */
|
|
67
|
+
chainStart(prompt: string): number | undefined {
|
|
68
|
+
return this.ages.get(prompt)?.startedAt;
|
|
69
|
+
}
|
|
70
|
+
|
|
62
71
|
/** A pending wakeup exists. */
|
|
63
72
|
hasPending(): boolean {
|
|
64
73
|
return this.store.list().some((job) => job.source === "wakeup");
|
|
@@ -82,10 +91,20 @@ export class DynamicLoop {
|
|
|
82
91
|
const wasClamped = !(delaySeconds >= MIN_DELAY_SECONDS && delaySeconds <= MAX_DELAY_SECONDS);
|
|
83
92
|
const scheduledFor = now + clamped * 1000;
|
|
84
93
|
const target = new Date(scheduledFor);
|
|
85
|
-
this.store.create({ cron: `${target.getMinutes()} ${target.getHours()} * * *`, prompt, recurring: false, source: "wakeup", fireAt: scheduledFor }, now);
|
|
94
|
+
const created = this.store.create({ cron: `${target.getMinutes()} ${target.getHours()} * * *`, prompt, recurring: false, source: "wakeup", fireAt: scheduledFor }, now);
|
|
86
95
|
this.ages.set(prompt, { startedAt, lastScheduledFor: scheduledFor });
|
|
87
96
|
if (viaKeepalive) this.misses++;
|
|
88
|
-
return { scheduledFor, clampedDelaySeconds: clamped, wasClamped };
|
|
97
|
+
return { scheduledFor, clampedDelaySeconds: clamped, wasClamped, startedAt, ...(created.ok ? { jobId: created.job.id } : {}) };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Take back a pending wakeup's loop state from the branch (a /reload, a
|
|
102
|
+
* /tree switch): its age, and whether the keepalive armed it, which spends
|
|
103
|
+
* the loop's one fallback.
|
|
104
|
+
*/
|
|
105
|
+
restoreChain(prompt: string, startedAt: number, scheduledFor: number, viaKeepalive: boolean): void {
|
|
106
|
+
this.ages.set(prompt, { startedAt, lastScheduledFor: scheduledFor });
|
|
107
|
+
this.misses = viaKeepalive ? 1 : 0;
|
|
89
108
|
}
|
|
90
109
|
|
|
91
110
|
/**
|