pi-cc-loop 0.7.2 → 0.7.4
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 +5 -2
- package/config.ts +18 -19
- package/cron.ts +0 -21
- package/index.ts +105 -29
- package/loopfile.ts +1 -3
- package/package.json +1 -1
- package/prompts.ts +4 -11
- package/render.ts +0 -5
- package/renderers.ts +3 -2
- package/settings.ts +34 -19
package/README.md
CHANGED
|
@@ -69,6 +69,8 @@ ln -sfn "$PWD" ~/.pi/agent/extensions/pi-cc-loop
|
|
|
69
69
|
| `cron_list` | `CronList` | 列出任务和实际的下次运行时间(已含错峰) |
|
|
70
70
|
| `schedule_wakeup` | `ScheduleWakeup` | 自定步调:`delaySeconds`(限制在 60–3600)、`reason`(必填,显示在触发块表头)、`prompt`;`stop: true` 结束 |
|
|
71
71
|
|
|
72
|
+
四个工具都声明了 `outputSchema`:开了 codemode 时,脚本里 `tools.cron_create(...)`、`tools.cron_list()` 拿到的是结构化结果(`{ id, nextFireAt, … }`、`{ jobs: [...] }`),一个脚本里就能列任务、按条件批量取消,不用从文本里抠 id。没开 codemode 时没有影响。
|
|
73
|
+
|
|
72
74
|
## 渲染
|
|
73
75
|
|
|
74
76
|
定时触发只显示任务本身,给模型看的信封不上屏:
|
|
@@ -94,12 +96,13 @@ Ctrl+O 展开后多一行元信息(任务 id、节奏)。`compactMessages`
|
|
|
94
96
|
"footer": true, // 底部摘要:loop 的一行和 Monitor 的 `monitor N`
|
|
95
97
|
"draft": true, // pi-cc-plugins:Ctrl+C 找回
|
|
96
98
|
"turnSummary": true, // pi-cc-plugins:回合摘要一行
|
|
99
|
+
"ccKeys": true, // pi-cc-plugins:Enter 排队、Ctrl+Enter 直发
|
|
97
100
|
"compaction": true, // pi-cc-compaction:Claude Code 的压缩
|
|
98
101
|
"idleCompaction": true // pi-cc-compaction:空闲压缩
|
|
99
102
|
}
|
|
100
103
|
```
|
|
101
104
|
|
|
102
|
-
draft、turnSummary 只被 pi-cc-plugins 读取,compaction、idleCompaction 只被 pi-cc-compaction 读取;没装对应包的会话里,面板中的这些项不生效,值原样保留。
|
|
105
|
+
draft、turnSummary、ccKeys 只被 pi-cc-plugins 读取,compaction、idleCompaction 只被 pi-cc-compaction 读取;没装对应包的会话里,面板中的这些项不生效,值原样保留。
|
|
103
106
|
|
|
104
107
|
在 TUI 里输入 `/pi_cc` 会打开设置面板,和 pi-cc-extensions 的 `/ccstyle` 一样按页组织,每个插件一页:
|
|
105
108
|
**Loop & Monitor**(消息样式、底部摘要,两个包共用)、**Plugins**(pi-cc-plugins)、**Compact**(pi-cc-compaction)。
|
|
@@ -126,7 +129,7 @@ Loop & Monitor / Plugins / Compact ~/.pi/agent/pi-cc.json
|
|
|
126
129
|
也可以用文字命令:`/pi_cc messages on|off`、`/pi_cc footer on|off`,适合脚本和 RPC;
|
|
127
130
|
非 TUI 模式下,不带参数的 `/pi_cc` 按页输出当前设置。命令只写改动的那一项,文件里的其他内容原样保留。
|
|
128
131
|
|
|
129
|
-
环境变量 `PI_CC_FOOTER`、`PI_CC_COMPACT_MESSAGES`(pi-cc-plugins
|
|
132
|
+
环境变量 `PI_CC_FOOTER`、`PI_CC_COMPACT_MESSAGES`(pi-cc-plugins 的三项是 `PI_CC_DRAFT`、`PI_CC_TURN_SUMMARY`、`PI_CC_KEYS`,pi-cc-compaction 的两项是 `PI_CC_COMPACTION`、`PI_CC_IDLE_COMPACTION`)优先于文件,但不会被写进文件。设着的时候,面板里这一项显示实际生效的值、不能切换,
|
|
130
133
|
说明里写出是哪个变量;文字输出会在对应项后面标出来(如 `hidden (PI_CC_FOOTER=0)`);用文字命令切换会写进文件,但提示会说明变量还在覆盖它。
|
|
131
134
|
|
|
132
135
|
## 底部摘要
|
package/config.ts
CHANGED
|
@@ -13,12 +13,13 @@
|
|
|
13
13
|
* "footer": true,
|
|
14
14
|
* "draft": true,
|
|
15
15
|
* "turnSummary": true,
|
|
16
|
+
* "ccKeys": true,
|
|
16
17
|
* "compaction": true,
|
|
17
18
|
* "idleCompaction": true
|
|
18
19
|
* }
|
|
19
20
|
*
|
|
20
|
-
* compactMessages and footer drive loop and monitor; draft and
|
|
21
|
-
* drive pi-cc-plugins; compaction and idleCompaction drive pi-cc-compaction. A package that is not installed simply ignores its
|
|
21
|
+
* compactMessages and footer drive loop and monitor; draft, turnSummary and
|
|
22
|
+
* ccKeys drive pi-cc-plugins; compaction and idleCompaction drive pi-cc-compaction. A package that is not installed simply ignores its
|
|
22
23
|
* keys, and they are kept as the user left them.
|
|
23
24
|
*
|
|
24
25
|
* Environment overrides (useful for tests and quick experiments) win over the
|
|
@@ -28,6 +29,7 @@
|
|
|
28
29
|
* PI_CC_FOOTER=0|1
|
|
29
30
|
* PI_CC_DRAFT=0|1
|
|
30
31
|
* PI_CC_TURN_SUMMARY=0|1
|
|
32
|
+
* PI_CC_KEYS=0|1
|
|
31
33
|
* PI_CC_COMPACTION=0|1
|
|
32
34
|
* PI_CC_IDLE_COMPACTION=0|1
|
|
33
35
|
*/
|
|
@@ -48,6 +50,8 @@ export interface PiCcConfig {
|
|
|
48
50
|
draft: boolean;
|
|
49
51
|
/** pi-cc-plugins: one line closes every agent run. */
|
|
50
52
|
turnSummary: boolean;
|
|
53
|
+
/** pi-cc-plugins: Claude Code's Enter queues, Ctrl+Enter sends now. */
|
|
54
|
+
ccKeys: boolean;
|
|
51
55
|
/** pi-cc-compaction: Claude Code's compaction replaces Pi's summary request. */
|
|
52
56
|
compaction: boolean;
|
|
53
57
|
/** pi-cc-compaction: compact while idle, before a long prompt cache expires. */
|
|
@@ -59,6 +63,7 @@ export const DEFAULT_CONFIG: PiCcConfig = {
|
|
|
59
63
|
footer: true,
|
|
60
64
|
draft: true,
|
|
61
65
|
turnSummary: true,
|
|
66
|
+
ccKeys: true,
|
|
62
67
|
compaction: true,
|
|
63
68
|
idleCompaction: true,
|
|
64
69
|
};
|
|
@@ -69,6 +74,7 @@ const ENV_OVERRIDES: Record<keyof PiCcConfig, string> = {
|
|
|
69
74
|
footer: "PI_CC_FOOTER",
|
|
70
75
|
draft: "PI_CC_DRAFT",
|
|
71
76
|
turnSummary: "PI_CC_TURN_SUMMARY",
|
|
77
|
+
ccKeys: "PI_CC_KEYS",
|
|
72
78
|
compaction: "PI_CC_COMPACTION",
|
|
73
79
|
idleCompaction: "PI_CC_IDLE_COMPACTION",
|
|
74
80
|
};
|
|
@@ -93,6 +99,11 @@ export function configPath(): string {
|
|
|
93
99
|
return path.join(getAgentDir(), "pi-cc.json");
|
|
94
100
|
}
|
|
95
101
|
|
|
102
|
+
/** One setting: an environment override, else the file's boolean, else the default. */
|
|
103
|
+
function readSetting(file: Partial<PiCcConfig>, key: keyof PiCcConfig): boolean {
|
|
104
|
+
return envBool(ENV_OVERRIDES[key]) ?? (typeof file[key] === "boolean" ? (file[key] as boolean) : DEFAULT_CONFIG[key]);
|
|
105
|
+
}
|
|
106
|
+
|
|
96
107
|
function readConfig(): PiCcConfig {
|
|
97
108
|
let file: Partial<PiCcConfig> = {};
|
|
98
109
|
try {
|
|
@@ -101,23 +112,11 @@ function readConfig(): PiCcConfig {
|
|
|
101
112
|
} catch {
|
|
102
113
|
// Missing or malformed files fall back to defaults.
|
|
103
114
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
envBool(ENV_OVERRIDES.footer) ?? (typeof file.footer === "boolean" ? file.footer : DEFAULT_CONFIG.footer),
|
|
110
|
-
draft: envBool(ENV_OVERRIDES.draft) ?? (typeof file.draft === "boolean" ? file.draft : DEFAULT_CONFIG.draft),
|
|
111
|
-
turnSummary:
|
|
112
|
-
envBool(ENV_OVERRIDES.turnSummary) ??
|
|
113
|
-
(typeof file.turnSummary === "boolean" ? file.turnSummary : DEFAULT_CONFIG.turnSummary),
|
|
114
|
-
compaction:
|
|
115
|
-
envBool(ENV_OVERRIDES.compaction) ??
|
|
116
|
-
(typeof file.compaction === "boolean" ? file.compaction : DEFAULT_CONFIG.compaction),
|
|
117
|
-
idleCompaction:
|
|
118
|
-
envBool(ENV_OVERRIDES.idleCompaction) ??
|
|
119
|
-
(typeof file.idleCompaction === "boolean" ? file.idleCompaction : DEFAULT_CONFIG.idleCompaction),
|
|
120
|
-
};
|
|
115
|
+
// One loop over the defaults, so a setting added to DEFAULT_CONFIG is read
|
|
116
|
+
// without a second edit here.
|
|
117
|
+
const config = { ...DEFAULT_CONFIG };
|
|
118
|
+
for (const key of Object.keys(config) as Array<keyof PiCcConfig>) config[key] = readSetting(file, key);
|
|
119
|
+
return config;
|
|
121
120
|
}
|
|
122
121
|
|
|
123
122
|
let cached: PiCcConfig | undefined;
|
package/cron.ts
CHANGED
|
@@ -158,27 +158,6 @@ export function isUnreachable(expression: string): boolean {
|
|
|
158
158
|
return nextFireAfter(fields, new Date(), 366) === null;
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
-
/**
|
|
162
|
-
* The divisor of `total` closest to `n`.
|
|
163
|
-
*
|
|
164
|
-
* Claude Code rounds a non-dividing interval to the nearest clean one: "If the
|
|
165
|
-
* interval doesn't cleanly divide its unit, round to the nearest clean interval
|
|
166
|
-
* and tell the user what you rounded to."
|
|
167
|
-
*/
|
|
168
|
-
function nearestDivisor(n: number, total: number): number {
|
|
169
|
-
let best = 1;
|
|
170
|
-
let bestDistance = Number.POSITIVE_INFINITY;
|
|
171
|
-
for (let d = 1; d <= total; d++) {
|
|
172
|
-
if (total % d !== 0) continue;
|
|
173
|
-
const distance = Math.abs(d - n);
|
|
174
|
-
if (distance < bestDistance) {
|
|
175
|
-
best = d;
|
|
176
|
-
bestDistance = distance;
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
return best;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
161
|
const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
|
183
162
|
|
|
184
163
|
|
package/index.ts
CHANGED
|
@@ -36,7 +36,7 @@ import {
|
|
|
36
36
|
parseCron,
|
|
37
37
|
} from "./cron.ts";
|
|
38
38
|
import { DurableRuntime, type Placement } from "./durable.ts";
|
|
39
|
-
import { displayPrompt, isLoopFileSentinel, LoopFileDelivery
|
|
39
|
+
import { displayPrompt, isLoopFileSentinel, LoopFileDelivery } from "./loopfile.ts";
|
|
40
40
|
import {
|
|
41
41
|
CRON_CREATE_DESCRIPTION,
|
|
42
42
|
CRON_CREATE_SNIPPET,
|
|
@@ -143,6 +143,14 @@ function normalizePrompt(prompt: string): string {
|
|
|
143
143
|
return prompt.trim().replace(/^\/loop\s+/i, "").trim();
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
/** A stored job as a codemode script sees it: every field, minus those holding no value (the value is JSON). */
|
|
147
|
+
function jobValue(job: LoopJob): Record<string, string | number | boolean> {
|
|
148
|
+
return Object.fromEntries(Object.entries(storedJob(job)).filter(([, value]) => value !== undefined)) as Record<
|
|
149
|
+
string,
|
|
150
|
+
string | number | boolean
|
|
151
|
+
>;
|
|
152
|
+
}
|
|
153
|
+
|
|
146
154
|
/** The live scheduler, for tests. */
|
|
147
155
|
let activeScheduler: LoopScheduler | null = null;
|
|
148
156
|
|
|
@@ -445,8 +453,8 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
445
453
|
}
|
|
446
454
|
|
|
447
455
|
/** The text the model gets for a prompt: the loop.md sentinel expands at fire time. */
|
|
448
|
-
function resolvePrompt(prompt: string
|
|
449
|
-
return isLoopFileSentinel(prompt) ? loopFile.resolve(lastCtx?.cwd ?? process.cwd()
|
|
456
|
+
function resolvePrompt(prompt: string): string {
|
|
457
|
+
return isLoopFileSentinel(prompt) ? loopFile.resolve(lastCtx?.cwd ?? process.cwd()) : prompt;
|
|
450
458
|
}
|
|
451
459
|
|
|
452
460
|
/**
|
|
@@ -456,7 +464,7 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
456
464
|
*/
|
|
457
465
|
function deliverTick(job: LoopJob): void {
|
|
458
466
|
const tick = Math.max(tickCounters.get(job.prompt) ?? 0, job.tickCount ?? 0) + 1;
|
|
459
|
-
const body = resolvePrompt(job.prompt
|
|
467
|
+
const body = resolvePrompt(job.prompt);
|
|
460
468
|
// A fixed-interval tick is what it is - a scheduled task - and a
|
|
461
469
|
// self-paced tick keeps the loop framing. The rules for the run are
|
|
462
470
|
// rebuilt from `details` in before_agent_start.
|
|
@@ -566,7 +574,7 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
566
574
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
567
575
|
lastCtx = ctx;
|
|
568
576
|
const raw = args.trim();
|
|
569
|
-
const [head
|
|
577
|
+
const [head] = raw.split(/\s+/);
|
|
570
578
|
|
|
571
579
|
// Settings moved to /pi_cc: these words stay recognized so they cannot
|
|
572
580
|
// start a loop by mistake (everything else is a loop request).
|
|
@@ -681,6 +689,16 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
681
689
|
),
|
|
682
690
|
}),
|
|
683
691
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
692
|
+
// What a codemode script gets: values, not the text (Pi sends the shape, not this).
|
|
693
|
+
outputSchema: Type.Object({
|
|
694
|
+
id: Type.String(),
|
|
695
|
+
cron: Type.String(),
|
|
696
|
+
recurring: Type.Boolean(),
|
|
697
|
+
durable: Type.Boolean(),
|
|
698
|
+
nextFireAt: Type.Optional(Type.Number()),
|
|
699
|
+
prompt: Type.String(),
|
|
700
|
+
runsHere: Type.Boolean(),
|
|
701
|
+
}),
|
|
684
702
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
685
703
|
lastCtx = ctx;
|
|
686
704
|
if (!parseCron(params.cron)) {
|
|
@@ -731,17 +749,21 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
731
749
|
// Asked for in plain words, not through /loop: do it once now, as /loop would.
|
|
732
750
|
if (recurring && !currentRun.loopRequest && !currentRun.cronId && !currentRun.chainId) lines.push(RUN_NOW_HINT);
|
|
733
751
|
|
|
752
|
+
const value = {
|
|
753
|
+
id: job.id,
|
|
754
|
+
cron: job.cron ?? params.cron.trim(),
|
|
755
|
+
recurring: job.recurring,
|
|
756
|
+
durable: job.durable,
|
|
757
|
+
...(nextAt !== undefined ? { nextFireAt: nextAt } : {}),
|
|
758
|
+
prompt: job.prompt,
|
|
759
|
+
runsHere: placement.runsHere,
|
|
760
|
+
};
|
|
761
|
+
|
|
734
762
|
return {
|
|
735
763
|
content: [{ type: "text", text: lines.join("\n") }],
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
recurring: job.recurring,
|
|
740
|
-
durable: job.durable,
|
|
741
|
-
nextFireAt: nextAt,
|
|
742
|
-
prompt: job.prompt,
|
|
743
|
-
runsHere: placement.runsHere,
|
|
744
|
-
},
|
|
764
|
+
// Shared with codemode scripts; no key holds undefined, so it is JSON.
|
|
765
|
+
details: value,
|
|
766
|
+
structuredContent: value,
|
|
745
767
|
};
|
|
746
768
|
},
|
|
747
769
|
});
|
|
@@ -755,6 +777,7 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
755
777
|
id: Type.String({ description: "The job id returned by cron_create, or the id of a self-paced loop." }),
|
|
756
778
|
}),
|
|
757
779
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
780
|
+
outputSchema: Type.Object({ cancelled: Type.Boolean(), id: Type.String() }),
|
|
758
781
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
759
782
|
lastCtx = ctx;
|
|
760
783
|
const job = scheduler.get(params.id);
|
|
@@ -763,14 +786,18 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
763
786
|
if (job.kind === "wakeup") retireChain(job, "cancelled with cron_delete");
|
|
764
787
|
else removeJob(job);
|
|
765
788
|
} else if (!durable?.remove(params.id)) {
|
|
789
|
+
const value = { cancelled: false, id: params.id };
|
|
766
790
|
return {
|
|
767
791
|
content: [{ type: "text" as const, text: `No scheduled job with id ${params.id}.` }],
|
|
768
|
-
details:
|
|
792
|
+
details: value,
|
|
793
|
+
structuredContent: value,
|
|
769
794
|
};
|
|
770
795
|
}
|
|
796
|
+
const value = { cancelled: true, id: params.id };
|
|
771
797
|
return {
|
|
772
798
|
content: [{ type: "text" as const, text: `Cancelled job ${params.id}.` }],
|
|
773
|
-
details:
|
|
799
|
+
details: value,
|
|
800
|
+
structuredContent: value,
|
|
774
801
|
};
|
|
775
802
|
},
|
|
776
803
|
});
|
|
@@ -782,13 +809,40 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
782
809
|
promptSnippet: CRON_LIST_SNIPPET,
|
|
783
810
|
parameters: Type.Object({}),
|
|
784
811
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
812
|
+
outputSchema: Type.Object({
|
|
813
|
+
jobs: Type.Array(
|
|
814
|
+
Type.Object({
|
|
815
|
+
id: Type.String(),
|
|
816
|
+
kind: Type.Union([Type.Literal("cron"), Type.Literal("wakeup")]),
|
|
817
|
+
cron: Type.Optional(Type.String()),
|
|
818
|
+
prompt: Type.String(),
|
|
819
|
+
recurring: Type.Boolean(),
|
|
820
|
+
createdAt: Type.Number(),
|
|
821
|
+
nextFireAt: Type.Optional(Type.Number()),
|
|
822
|
+
scheduledFor: Type.Optional(Type.Number()),
|
|
823
|
+
chainStartedAt: Type.Optional(Type.Number()),
|
|
824
|
+
keepaliveUsed: Type.Optional(Type.Boolean()),
|
|
825
|
+
fireCount: Type.Optional(Type.Number()),
|
|
826
|
+
tickCount: Type.Optional(Type.Number()),
|
|
827
|
+
lastFiredAt: Type.Optional(Type.Number()),
|
|
828
|
+
maxFires: Type.Optional(Type.Number()),
|
|
829
|
+
durable: Type.Boolean(),
|
|
830
|
+
createdInProject: Type.Optional(Type.String()),
|
|
831
|
+
label: Type.Optional(Type.String()),
|
|
832
|
+
reason: Type.Optional(Type.String()),
|
|
833
|
+
source: Type.String(),
|
|
834
|
+
}),
|
|
835
|
+
),
|
|
836
|
+
}),
|
|
785
837
|
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
786
838
|
lastCtx = ctx;
|
|
787
839
|
const jobs = listJobs();
|
|
840
|
+
const values = jobs.map(jobValue);
|
|
788
841
|
if (!jobs.length) {
|
|
789
842
|
return {
|
|
790
843
|
content: [{ type: "text" as const, text: "No scheduled jobs." }],
|
|
791
|
-
details: { jobs:
|
|
844
|
+
details: { jobs: values },
|
|
845
|
+
structuredContent: { jobs: values },
|
|
792
846
|
};
|
|
793
847
|
}
|
|
794
848
|
const text = jobs
|
|
@@ -802,7 +856,8 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
802
856
|
.join("\n");
|
|
803
857
|
return {
|
|
804
858
|
content: [{ type: "text" as const, text }],
|
|
805
|
-
details: { jobs:
|
|
859
|
+
details: { jobs: values },
|
|
860
|
+
structuredContent: { jobs: values },
|
|
806
861
|
};
|
|
807
862
|
},
|
|
808
863
|
});
|
|
@@ -836,6 +891,17 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
836
891
|
),
|
|
837
892
|
}),
|
|
838
893
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
894
|
+
// Every return is one of these shapes: a stop, a suppression, an age-out, or a schedule.
|
|
895
|
+
outputSchema: Type.Object({
|
|
896
|
+
stopped: Type.Boolean(),
|
|
897
|
+
cancelledWakeups: Type.Optional(Type.Number()),
|
|
898
|
+
agedOut: Type.Optional(Type.Boolean()),
|
|
899
|
+
suppressed: Type.Optional(Type.String()),
|
|
900
|
+
id: Type.Optional(Type.String()),
|
|
901
|
+
scheduledFor: Type.Optional(Type.Number()),
|
|
902
|
+
clampedDelaySeconds: Type.Optional(Type.Number()),
|
|
903
|
+
wasClamped: Type.Optional(Type.Boolean()),
|
|
904
|
+
}),
|
|
839
905
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
840
906
|
lastCtx = ctx;
|
|
841
907
|
|
|
@@ -845,6 +911,7 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
845
911
|
const named = params.prompt ? scheduler.findChain(normalizePrompt(params.prompt)) : undefined;
|
|
846
912
|
const chains = current ? [current] : named ? [named] : scheduler.byKind("wakeup");
|
|
847
913
|
for (const chain of chains) retireChain(chain, "stopped by the agent");
|
|
914
|
+
const value = { stopped: true, cancelledWakeups: chains.length };
|
|
848
915
|
return {
|
|
849
916
|
content: [
|
|
850
917
|
{
|
|
@@ -856,7 +923,8 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
856
923
|
"If you are running a fixed-interval /loop (a recurring cron), it is NOT stopped by this call \u2014 cancel it with cron_delete.",
|
|
857
924
|
},
|
|
858
925
|
],
|
|
859
|
-
details:
|
|
926
|
+
details: value,
|
|
927
|
+
structuredContent: value,
|
|
860
928
|
};
|
|
861
929
|
}
|
|
862
930
|
|
|
@@ -876,6 +944,7 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
876
944
|
// Returning it as a result (instead of throwing) keeps the transcript
|
|
877
945
|
// calm and tells the model nothing is missing.
|
|
878
946
|
const cronJob = scheduler.get(currentRun.cronId);
|
|
947
|
+
const value = { suppressed: "fixed-interval", id: currentRun.cronId, stopped: false };
|
|
879
948
|
return {
|
|
880
949
|
content: [
|
|
881
950
|
{
|
|
@@ -886,7 +955,8 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
886
955
|
`Nothing was scheduled. To end the loop, call cron_delete with id ${currentRun.cronId}.`,
|
|
887
956
|
},
|
|
888
957
|
],
|
|
889
|
-
details:
|
|
958
|
+
details: value,
|
|
959
|
+
structuredContent: value,
|
|
890
960
|
};
|
|
891
961
|
}
|
|
892
962
|
|
|
@@ -904,6 +974,7 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
904
974
|
const limit = maxAgeMs();
|
|
905
975
|
if (limit > 0 && now - (chain.chainStartedAt ?? chain.createdAt) >= limit) {
|
|
906
976
|
retireChain(chain, `reached its ${maxAgeDays()}-day limit`);
|
|
977
|
+
const value = { stopped: true, agedOut: true, id: chain.id, cancelledWakeups: 1 };
|
|
907
978
|
return {
|
|
908
979
|
content: [
|
|
909
980
|
{
|
|
@@ -913,7 +984,8 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
913
984
|
"do not re-issue. Tell the user they can start a new /loop.",
|
|
914
985
|
},
|
|
915
986
|
],
|
|
916
|
-
details:
|
|
987
|
+
details: value,
|
|
988
|
+
structuredContent: value,
|
|
917
989
|
};
|
|
918
990
|
}
|
|
919
991
|
} else {
|
|
@@ -950,6 +1022,13 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
950
1022
|
|
|
951
1023
|
const note = wasClamped ? ` (clamped to ${delay}s from your requested value)` : "";
|
|
952
1024
|
const clock = new Date(scheduledFor).toTimeString().slice(0, 8);
|
|
1025
|
+
const value = {
|
|
1026
|
+
id: chain.id,
|
|
1027
|
+
scheduledFor,
|
|
1028
|
+
clampedDelaySeconds: delay,
|
|
1029
|
+
wasClamped,
|
|
1030
|
+
stopped: false,
|
|
1031
|
+
};
|
|
953
1032
|
return {
|
|
954
1033
|
content: [
|
|
955
1034
|
{
|
|
@@ -959,13 +1038,8 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
959
1038
|
"This turn ends when this call returns \u2014 you are re-invoked when the wakeup fires or a monitor event arrives.",
|
|
960
1039
|
},
|
|
961
1040
|
],
|
|
962
|
-
details:
|
|
963
|
-
|
|
964
|
-
scheduledFor,
|
|
965
|
-
clampedDelaySeconds: delay,
|
|
966
|
-
wasClamped,
|
|
967
|
-
stopped: false,
|
|
968
|
-
},
|
|
1041
|
+
details: value,
|
|
1042
|
+
structuredContent: value,
|
|
969
1043
|
// Claude Code ends the turn when ScheduleWakeup returns (on models
|
|
970
1044
|
// that support it); the guidelines ask for the update first. Pi only
|
|
971
1045
|
// ends the turn when every tool in the batch agrees.
|
|
@@ -1027,8 +1101,10 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
1027
1101
|
|
|
1028
1102
|
if (event.message.customType === MONITOR_EVENT_TYPE) {
|
|
1029
1103
|
// A monitor bound to a loop woke the agent: the run belongs to that loop.
|
|
1104
|
+
// The monitor stores the prompt the model gave it; the chain is keyed
|
|
1105
|
+
// without a leading `/loop`, so both spellings find it.
|
|
1030
1106
|
const loopPrompt = (event.message.details as { loopPrompt?: unknown } | undefined)?.loopPrompt;
|
|
1031
|
-
const chain = typeof loopPrompt === "string" ? scheduler.findChain(loopPrompt) : undefined;
|
|
1107
|
+
const chain = typeof loopPrompt === "string" ? scheduler.findChain(normalizePrompt(loopPrompt)) : undefined;
|
|
1032
1108
|
if (chain) currentRun.chainId = chain.id;
|
|
1033
1109
|
return;
|
|
1034
1110
|
}
|
package/loopfile.ts
CHANGED
|
@@ -59,8 +59,6 @@ function truncate(content: string): string {
|
|
|
59
59
|
return `${content.slice(0, cut > 0 ? cut : LOOP_FILE_MAX_CHARS)}\n> WARNING: loop.md was truncated to ${LOOP_FILE_MAX_CHARS} bytes. Keep the task list concise.`;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
export type TickMode = "cron" | "wakeup";
|
|
63
|
-
|
|
64
62
|
/** Remembers what a session was last given, so unchanged tasks are not resent. */
|
|
65
63
|
export class LoopFileDelivery {
|
|
66
64
|
private lastDelivered: string | null = null;
|
|
@@ -71,7 +69,7 @@ export class LoopFileDelivery {
|
|
|
71
69
|
}
|
|
72
70
|
|
|
73
71
|
/** The text of one tick for the loop.md sentinel. */
|
|
74
|
-
resolve(cwd: string
|
|
72
|
+
resolve(cwd: string): string {
|
|
75
73
|
const file = readLoopFile(cwd);
|
|
76
74
|
if (file) {
|
|
77
75
|
if (this.lastDelivered === file.content) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-cc-loop",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.4",
|
|
4
4
|
"description": "Claude Code compatible /loop for the Pi coding agent: cron-scheduled fixed intervals, self-paced chains, and the same dispatch semantics as Claude Code.",
|
|
5
5
|
"author": "tarnish233",
|
|
6
6
|
"type": "module",
|
package/prompts.ts
CHANGED
|
@@ -45,19 +45,12 @@ const DELAY_ADVICE = [
|
|
|
45
45
|
" fallback (1200s+) instead.",
|
|
46
46
|
].join("\n");
|
|
47
47
|
|
|
48
|
-
/**
|
|
49
|
-
* The rules for a `/loop <prompt>` whose interval the command layer could not
|
|
50
|
-
* parse. Claude Code's /loop is a skill: the model reads the input and works
|
|
51
|
-
* the schedule out itself, so an interval written in any language works.
|
|
52
|
-
*/
|
|
53
48
|
/**
|
|
54
49
|
* The instructions for one `/loop` request. Claude Code's `/loop` is a skill:
|
|
55
50
|
* the command turns into a user message carrying these rules, and the model
|
|
56
51
|
* drives CronCreate / CronList / CronDelete / ScheduleWakeup itself. Nothing is
|
|
57
52
|
* created by code, so the conversation shows what was asked and what was done.
|
|
58
53
|
*/
|
|
59
|
-
const LOOP_FILE_SENTINEL_PLACEHOLDER = "__loop_md_sentinel__";
|
|
60
|
-
|
|
61
54
|
const LOOP_COMMAND_GUIDELINES = [
|
|
62
55
|
"This is a `/loop` request from the user: {raw}",
|
|
63
56
|
"",
|
|
@@ -91,7 +84,7 @@ const LOOP_COMMAND_GUIDELINES = [
|
|
|
91
84
|
"- `--max N`: pass `maxFires: N`.",
|
|
92
85
|
"",
|
|
93
86
|
"## loop.md",
|
|
94
|
-
"When the request has no task of its own (bare `/loop`, or an interval alone), pass the literal sentinel
|
|
87
|
+
"When the request has no task of its own (bare `/loop`, or an interval alone), pass the literal sentinel {sentinel} as the prompt: it expands at fire time to `.pi/loop.md`, or to the built-in maintenance pass when there is no loop.md.",
|
|
95
88
|
"For the run you do right now, read `.pi/loop.md` (or `~/.pi/loop.md`) yourself and do those tasks; the sentinel only expands on later fires.",
|
|
96
89
|
].join("\n");
|
|
97
90
|
|
|
@@ -155,12 +148,12 @@ export function scheduledTaskProvenance(id: string, schedule: string): string {
|
|
|
155
148
|
}
|
|
156
149
|
|
|
157
150
|
export function loopCommandGuidelines(raw: string): string {
|
|
158
|
-
const quoted = quote(raw || LOOP_FILE_SENTINEL);
|
|
159
151
|
return fill(LOOP_COMMAND_GUIDELINES, {
|
|
160
152
|
raw: raw ? `${quote(raw)}` : "(empty: the loop.md tasks)",
|
|
161
|
-
quoted,
|
|
153
|
+
quoted: quote(raw || LOOP_FILE_SENTINEL),
|
|
162
154
|
delayAdvice: DELAY_ADVICE,
|
|
163
|
-
|
|
155
|
+
sentinel: quote(LOOP_FILE_SENTINEL),
|
|
156
|
+
});
|
|
164
157
|
}
|
|
165
158
|
|
|
166
159
|
export function loopGuidelines(prompt: string): string {
|
package/render.ts
CHANGED
|
@@ -36,11 +36,6 @@ export function contentText(content: unknown): string {
|
|
|
36
36
|
return "";
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
/** Remove the provenance line the model needs but the reader does not. */
|
|
40
|
-
export function stripProvenance(text: string, pattern: RegExp): string {
|
|
41
|
-
return text.replace(pattern, "");
|
|
42
|
-
}
|
|
43
|
-
|
|
44
39
|
/** Cap a string for a display line, with an ellipsis. */
|
|
45
40
|
export function truncate(text: string, max: number): string {
|
|
46
41
|
const flat = text.replace(/\s+/g, " ").trim();
|
package/renderers.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
8
|
-
import { bodyLines, contentText, formatStamp, renderBlock, renderCard
|
|
8
|
+
import { bodyLines, contentText, formatStamp, renderBlock, renderCard } from "./render.ts";
|
|
9
9
|
import { loadConfig } from "./config.ts";
|
|
10
10
|
import { humanSchedule } from "./cron.ts";
|
|
11
11
|
import { displayPrompt } from "./loopfile.ts";
|
|
@@ -56,7 +56,8 @@ export interface ScheduledDetails {
|
|
|
56
56
|
*/
|
|
57
57
|
export function renderScheduledMessage(message: any, options: any, theme: Theme): any {
|
|
58
58
|
const details = (message.details ?? {}) as ScheduledDetails;
|
|
59
|
-
|
|
59
|
+
// The provenance line is addressed to the model, not the reader.
|
|
60
|
+
const prompt = details.prompt ?? contentText(message.content).replace(/^\[[^\]]*\]\n?/, "");
|
|
60
61
|
// A fixed interval fires a scheduled task; only a self-paced tick is a loop.
|
|
61
62
|
const label = details.missed
|
|
62
63
|
? "\u273b loop \u00b7 missed jobs"
|
package/settings.ts
CHANGED
|
@@ -100,6 +100,14 @@ const PAGES: SettingsPage[] = [
|
|
|
100
100
|
off: "off",
|
|
101
101
|
description: "One line closes every agent run \u2014 tools, duration, done time.",
|
|
102
102
|
},
|
|
103
|
+
{
|
|
104
|
+
field: "ccKeys",
|
|
105
|
+
label: "CC keys",
|
|
106
|
+
on: "on",
|
|
107
|
+
off: "off",
|
|
108
|
+
description:
|
|
109
|
+
"Enter queues while Pi works and Ctrl+Enter sends now, as in Claude Code. off: Pi's keys (Enter sends, Alt+Enter queues).",
|
|
110
|
+
},
|
|
103
111
|
],
|
|
104
112
|
},
|
|
105
113
|
{
|
|
@@ -262,27 +270,32 @@ function overrideNote(key: keyof PiCcConfig): string {
|
|
|
262
270
|
return override ? ` (${override})` : "";
|
|
263
271
|
}
|
|
264
272
|
|
|
273
|
+
/**
|
|
274
|
+
* The word the text summary uses for a setting: the first alias in KEYS. The
|
|
275
|
+
* panel shows the description label instead.
|
|
276
|
+
*/
|
|
277
|
+
function commandWord(field: keyof PiCcConfig): string {
|
|
278
|
+
return Object.keys(KEYS).find((word) => KEYS[word].field === field) ?? field;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* The summary a non-TUI /pi_cc prints, generated from the same pages the panel
|
|
283
|
+
* draws: a setting added to PAGES cannot go missing here.
|
|
284
|
+
*/
|
|
265
285
|
function showSettings(ctx: ExtensionContext): void {
|
|
266
286
|
const config = loadConfig();
|
|
267
|
-
const lines = [
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
` compact: ${config.compaction ? "on" : "off"}${overrideNote("compaction")}`,
|
|
277
|
-
` idle: ${config.idleCompaction ? "on" : "off"}${overrideNote("idleCompaction")}`,
|
|
287
|
+
const lines = [`pi-cc settings \u00b7 ${configPath()}`];
|
|
288
|
+
for (const page of PAGES) {
|
|
289
|
+
lines.push(page.label);
|
|
290
|
+
for (const spec of page.settings) {
|
|
291
|
+
const value = config[spec.field] ? spec.on : spec.off;
|
|
292
|
+
lines.push(` ${commandWord(spec.field)}:`.padEnd(13) + value + overrideNote(spec.field));
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
lines.push(
|
|
278
296
|
"",
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
" /pi_cc draft on|off",
|
|
282
|
-
" /pi_cc summary on|off",
|
|
283
|
-
" /pi_cc compact on|off",
|
|
284
|
-
" /pi_cc idle on|off",
|
|
285
|
-
];
|
|
297
|
+
`Usage: /pi_cc <item> on|off (${SETTINGS.map((spec) => commandWord(spec.field)).join(", ")})`,
|
|
298
|
+
);
|
|
286
299
|
notify(ctx, lines.join("\n"), "info");
|
|
287
300
|
}
|
|
288
301
|
|
|
@@ -327,6 +340,8 @@ const KEYS: Record<string, { field: keyof PiCcConfig; label: string }> = {
|
|
|
327
340
|
draft: { field: "draft", label: "Draft recall" },
|
|
328
341
|
summary: { field: "turnSummary", label: "Turn summary" },
|
|
329
342
|
turnsummary: { field: "turnSummary", label: "Turn summary" },
|
|
343
|
+
keys: { field: "ccKeys", label: "CC keys" },
|
|
344
|
+
keymap: { field: "ccKeys", label: "CC keys" },
|
|
330
345
|
compact: { field: "compaction", label: "CC compaction" },
|
|
331
346
|
compaction: { field: "compaction", label: "CC compaction" },
|
|
332
347
|
idle: { field: "idleCompaction", label: "Idle compaction" },
|
|
@@ -381,7 +396,7 @@ export function registerPiCcCommand(pi: ExtensionAPI, owner: string): void {
|
|
|
381
396
|
};
|
|
382
397
|
|
|
383
398
|
pi.registerCommand("pi_cc", {
|
|
384
|
-
description: "pi-cc settings: message style, footer, draft, turn summary, compaction",
|
|
399
|
+
description: "pi-cc settings: message style, footer, draft, turn summary, CC keys, compaction",
|
|
385
400
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
386
401
|
const words = args.trim().split(/\s+/).filter(Boolean);
|
|
387
402
|
// "/pi_cc settings footer on" reads like the old /loop settings: drop the noun.
|