@ryan_nookpi/pi-extension-delayed-action 0.2.2 → 0.3.1
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 +31 -11
- package/index.ts +258 -214
- package/package.json +12 -3
- package/parse.ts +102 -0
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @ryan_nookpi/pi-extension-delayed-action
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
지정한 시간 뒤에 프롬프트를 다시 제출해 Pi 턴을 트리거하는 delay 익스텐션입니다.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
이 패키지는 `/delay` 명령어와 `delay` tool을 제공합니다. 시간이 되면 예약한 프롬프트를 사용자 메시지처럼 제출하며, 에이전트가 작업 중이면 follow-up으로 큐잉합니다.
|
|
6
6
|
|
|
7
7
|
## 설치
|
|
8
8
|
|
|
@@ -14,20 +14,40 @@ pi install npm:@ryan_nookpi/pi-extension-delayed-action
|
|
|
14
14
|
|
|
15
15
|
- 배포 후 몇 분 뒤 로그를 다시 확인하고 싶을 때
|
|
16
16
|
- 잠시 뒤에 후속 작업을 이어서 처리하고 싶을 때
|
|
17
|
-
-
|
|
17
|
+
- 같은 인터랙티브 세션 안에서 일회성 리마인더를 예약하고 싶을 때
|
|
18
18
|
|
|
19
19
|
## 사용 예시
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
```text
|
|
22
|
+
/delay 5m 상태 확인해줘
|
|
23
|
+
/delay 1h30m 회의록 정리 시작
|
|
24
|
+
/delay 2시간 배포 결과 확인
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
지원 단위:
|
|
28
|
+
|
|
29
|
+
- `ms`, `s`, `m`, `h`, `d`
|
|
30
|
+
- `초`, `분`, `시간`, `일`
|
|
31
|
+
- 조합형 예: `1h30m`
|
|
24
32
|
|
|
25
|
-
##
|
|
33
|
+
## 명령어
|
|
26
34
|
|
|
27
35
|
```text
|
|
28
|
-
/
|
|
29
|
-
/
|
|
30
|
-
/
|
|
36
|
+
/delay <duration> <prompt> 지연 후 프롬프트 제출
|
|
37
|
+
/delay list 예약 목록 보기
|
|
38
|
+
/delay-cancel [id|all] 예약 취소, id 생략 시 전체 취소
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Tool
|
|
42
|
+
|
|
43
|
+
에이전트는 `delay` tool을 사용해 다음 형식으로 예약할 수 있습니다.
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
{
|
|
47
|
+
"delay": "5m",
|
|
48
|
+
"prompt": "배포 로그 확인해줘",
|
|
49
|
+
"id": "optional-id"
|
|
50
|
+
}
|
|
31
51
|
```
|
|
32
52
|
|
|
33
|
-
|
|
53
|
+
예약은 세션 내 메모리 기반이며, 세션 종료 시 취소됩니다. 반복/영구 스케줄링이 필요하면 cron 계열 기능을 사용하세요.
|
package/index.ts
CHANGED
|
@@ -1,36 +1,38 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import { Type } from "@sinclair/typebox";
|
|
4
|
+
import { parseDelayArgs, parseDurationMs } from "./parse.ts";
|
|
2
5
|
|
|
3
|
-
const
|
|
4
|
-
const DEFAULT_SOON_DELAY_MS = 10 * 60 * 1000; // "좀 있다가" 기본값: 10분
|
|
5
|
-
const MAX_DELAY_MS = 7 * 24 * 60 * 60 * 1000; // 7일
|
|
6
|
+
const STATUS_KEY = "delay";
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
delayLabel: string;
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
type Reminder = {
|
|
14
|
-
id: number;
|
|
15
|
-
task: string;
|
|
16
|
-
delayMs: number;
|
|
17
|
-
delayLabel: string;
|
|
8
|
+
interface DelayTask {
|
|
9
|
+
id: string;
|
|
10
|
+
prompt: string;
|
|
18
11
|
createdAt: number;
|
|
19
12
|
dueAt: number;
|
|
20
13
|
timer: ReturnType<typeof setTimeout>;
|
|
21
|
-
|
|
14
|
+
ctx: ExtensionContext;
|
|
15
|
+
}
|
|
22
16
|
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
17
|
+
const DelayParamsSchema = Type.Object({
|
|
18
|
+
delay: Type.String({ description: "Delay duration such as 30s, 5m, 1h, 1h30m, 2시간." }),
|
|
19
|
+
prompt: Type.String({ description: "Prompt text to submit to the agent after the delay (triggers a turn)." }),
|
|
20
|
+
id: Type.Optional(Type.String({ description: "Optional task id. Auto-generated when omitted." })),
|
|
21
|
+
});
|
|
26
22
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
23
|
+
interface DelayToolParams {
|
|
24
|
+
delay: string;
|
|
25
|
+
prompt: string;
|
|
26
|
+
id?: string;
|
|
31
27
|
}
|
|
32
28
|
|
|
33
|
-
|
|
29
|
+
const tasks = new Map<string, DelayTask>();
|
|
30
|
+
let nextId = 1;
|
|
31
|
+
let latestCtx: ExtensionContext | undefined;
|
|
32
|
+
let api: ExtensionAPI | undefined;
|
|
33
|
+
let statusInterval: ReturnType<typeof setInterval> | undefined;
|
|
34
|
+
|
|
35
|
+
function formatKoreanDuration(ms: number): string {
|
|
34
36
|
if (ms < 60_000) return `${Math.max(1, Math.round(ms / 1000))}초`;
|
|
35
37
|
if (ms < 3_600_000) return `${Math.max(1, Math.round(ms / 60_000))}분`;
|
|
36
38
|
|
|
@@ -40,239 +42,281 @@ function formatDuration(ms: number): string {
|
|
|
40
42
|
return `${hours}시간 ${minutes}분`;
|
|
41
43
|
}
|
|
42
44
|
|
|
43
|
-
function
|
|
44
|
-
|
|
45
|
+
function allocateId(requested?: string): string {
|
|
46
|
+
const base = requested?.trim() || `delay-${nextId++}`;
|
|
47
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(base)) throw new Error(`Invalid delay id: ${base}`);
|
|
48
|
+
if (!tasks.has(base)) return base;
|
|
49
|
+
if (requested) throw new Error(`Delay id already exists: ${base}`);
|
|
50
|
+
return allocateId(`delay-${nextId++}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function preview(text: string, max = 80): string {
|
|
54
|
+
const oneLine = text.replace(/\s+/g, " ").trim();
|
|
55
|
+
return oneLine.length > max ? `${oneLine.slice(0, max - 1)}…` : oneLine;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function formatTask(task: DelayTask, now = Date.now()): string {
|
|
59
|
+
const remaining = Math.max(0, task.dueAt - now);
|
|
60
|
+
const dueTime = new Date(task.dueAt).toLocaleTimeString("ko-KR", {
|
|
45
61
|
hour: "2-digit",
|
|
46
62
|
minute: "2-digit",
|
|
47
63
|
second: "2-digit",
|
|
48
64
|
hour12: false,
|
|
49
65
|
});
|
|
66
|
+
return `${task.id} · ${formatKoreanDuration(remaining)} 후 (${dueTime}) · ${preview(task.prompt)}`;
|
|
50
67
|
}
|
|
51
68
|
|
|
52
|
-
function
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const task = explicit[3]?.trim() ?? "";
|
|
61
|
-
if (!Number.isFinite(amount) || amount <= 0 || !task) return null;
|
|
62
|
-
|
|
63
|
-
const delayMs = toDelayMs(amount, unit);
|
|
64
|
-
if (delayMs > MAX_DELAY_MS) return null;
|
|
69
|
+
function listTasks(): string {
|
|
70
|
+
if (tasks.size === 0) return "예약된 delay가 없어요.";
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
return [
|
|
73
|
+
"예약된 delay:",
|
|
74
|
+
...[...tasks.values()].sort((a, b) => a.dueAt - b.dueAt).map((task) => `- ${formatTask(task, now)}`),
|
|
75
|
+
].join("\n");
|
|
76
|
+
}
|
|
65
77
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
};
|
|
71
|
-
}
|
|
78
|
+
function paintStatus(ctx: ExtensionContext, text: string): string {
|
|
79
|
+
const theme = ctx.ui.theme as { fg?: unknown } | undefined;
|
|
80
|
+
return typeof theme?.fg === "function" ? theme.fg("accent", text) : text;
|
|
81
|
+
}
|
|
72
82
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
+
function refreshStatus(): void {
|
|
84
|
+
const ctx = latestCtx;
|
|
85
|
+
if (!ctx?.hasUI) return;
|
|
86
|
+
try {
|
|
87
|
+
if (tasks.size === 0) {
|
|
88
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const next = [...tasks.values()].sort((a, b) => a.dueAt - b.dueAt)[0];
|
|
92
|
+
ctx.ui.setStatus(
|
|
93
|
+
STATUS_KEY,
|
|
94
|
+
paintStatus(ctx, `⏰ ${tasks.size} · ${formatKoreanDuration(next.dueAt - Date.now())}`),
|
|
95
|
+
);
|
|
96
|
+
} catch {}
|
|
97
|
+
}
|
|
83
98
|
|
|
84
|
-
|
|
99
|
+
function ensureStatusTicker(): void {
|
|
100
|
+
if (statusInterval) return;
|
|
101
|
+
statusInterval = setInterval(refreshStatus, 1000);
|
|
85
102
|
}
|
|
86
103
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
104
|
+
function stopStatusTickerIfIdle(): void {
|
|
105
|
+
if (tasks.size > 0 || !statusInterval) return;
|
|
106
|
+
clearInterval(statusInterval);
|
|
107
|
+
statusInterval = undefined;
|
|
108
|
+
}
|
|
92
109
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
110
|
+
function injectPrompt(task: DelayTask): void {
|
|
111
|
+
try {
|
|
112
|
+
if (!api) throw new Error("delay runtime is not initialized.");
|
|
113
|
+
const idle = task.ctx.isIdle();
|
|
114
|
+
// idle이면 즉시 턴을 트리거하고, 작업 중이면 현재 턴 종료 후 실행되도록 followUp으로 큐잉한다.
|
|
115
|
+
api.sendUserMessage(task.prompt, idle ? undefined : { deliverAs: "followUp" });
|
|
116
|
+
if (task.ctx.hasUI) {
|
|
117
|
+
task.ctx.ui.notify(
|
|
118
|
+
idle
|
|
119
|
+
? `⏰ ${task.id} 시간이 되어 프롬프트를 실행했어요.`
|
|
120
|
+
: `⏰ ${task.id} 작업 중이라 followUp으로 예약했어요.`,
|
|
121
|
+
"info",
|
|
122
|
+
);
|
|
96
123
|
}
|
|
97
|
-
|
|
98
|
-
|
|
124
|
+
} catch (err) {
|
|
125
|
+
if (task.ctx.hasUI) {
|
|
126
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
127
|
+
task.ctx.ui.notify(`⏰ ${task.id} 프롬프트 실행 실패: ${message}`, "error");
|
|
128
|
+
}
|
|
129
|
+
} finally {
|
|
130
|
+
tasks.delete(task.id);
|
|
131
|
+
refreshStatus();
|
|
132
|
+
stopStatusTickerIfIdle();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
99
135
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
136
|
+
function scheduleDelay(ctx: ExtensionContext, delayMs: number, prompt: string, requestedId?: string): DelayTask {
|
|
137
|
+
if (!ctx.hasUI) throw new Error("delay requires an interactive UI so it can submit the prompt to the agent.");
|
|
138
|
+
latestCtx = ctx;
|
|
139
|
+
const id = allocateId(requestedId);
|
|
140
|
+
const createdAt = Date.now();
|
|
141
|
+
const task: DelayTask = {
|
|
142
|
+
id,
|
|
143
|
+
prompt,
|
|
144
|
+
createdAt,
|
|
145
|
+
dueAt: createdAt + delayMs,
|
|
146
|
+
timer: setTimeout(() => injectPrompt(task), delayMs),
|
|
147
|
+
ctx,
|
|
108
148
|
};
|
|
149
|
+
tasks.set(id, task);
|
|
150
|
+
ensureStatusTicker();
|
|
151
|
+
refreshStatus();
|
|
152
|
+
return task;
|
|
153
|
+
}
|
|
109
154
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
155
|
+
function cancelTask(id: string): boolean {
|
|
156
|
+
const task = tasks.get(id);
|
|
157
|
+
if (!task) return false;
|
|
158
|
+
clearTimeout(task.timer);
|
|
159
|
+
tasks.delete(id);
|
|
160
|
+
refreshStatus();
|
|
161
|
+
stopStatusTickerIfIdle();
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
115
164
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
createdAt: reminder.createdAt,
|
|
125
|
-
},
|
|
126
|
-
});
|
|
165
|
+
function cancelAll(): number {
|
|
166
|
+
const count = tasks.size;
|
|
167
|
+
for (const task of tasks.values()) clearTimeout(task.timer);
|
|
168
|
+
tasks.clear();
|
|
169
|
+
refreshStatus();
|
|
170
|
+
stopStatusTickerIfIdle();
|
|
171
|
+
return count;
|
|
172
|
+
}
|
|
127
173
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
174
|
+
function helpText(): string {
|
|
175
|
+
return [
|
|
176
|
+
"Usage:",
|
|
177
|
+
" /delay <duration> <prompt> 지연 후 프롬프트를 제출하고 턴을 트리거",
|
|
178
|
+
" /delay list 예약 목록 보기",
|
|
179
|
+
" /delay-cancel [id|all] 예약 취소 (id 생략 시 전체 취소)",
|
|
180
|
+
"",
|
|
181
|
+
"Examples:",
|
|
182
|
+
" /delay 5m 상태 확인해줘",
|
|
183
|
+
" /delay 1h30m 회의록 정리 시작",
|
|
184
|
+
" /delay 2시간 배포 결과 확인",
|
|
185
|
+
"",
|
|
186
|
+
"Duration units: ms, s, m, h, d, 초, 분, 시간, 일",
|
|
187
|
+
].join("\n");
|
|
188
|
+
}
|
|
134
189
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
190
|
+
function scheduleFromCommand(args: string, ctx: ExtensionContext): string {
|
|
191
|
+
const parsed = parseDelayArgs(args);
|
|
192
|
+
if ("error" in parsed) return parsed.error;
|
|
193
|
+
const task = scheduleDelay(ctx, parsed.delayMs, parsed.prompt);
|
|
194
|
+
return `✓ ${task.id} 예약됨: ${formatKoreanDuration(parsed.delayMs)} 후 제출 · ${preview(task.prompt)}`;
|
|
195
|
+
}
|
|
139
196
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
197
|
+
async function handleDelayCommand(args: string, ctx: ExtensionContext): Promise<string> {
|
|
198
|
+
const trimmed = args.trim();
|
|
199
|
+
if (!trimmed || trimmed === "help" || trimmed === "--help" || trimmed === "-h") return helpText();
|
|
200
|
+
if (trimmed === "list" || trimmed === "ls" || trimmed === "status") return listTasks();
|
|
144
201
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
id,
|
|
148
|
-
task: parsed.task,
|
|
149
|
-
delayMs: parsed.delayMs,
|
|
150
|
-
delayLabel: parsed.delayLabel,
|
|
151
|
-
createdAt,
|
|
152
|
-
dueAt,
|
|
153
|
-
timer,
|
|
154
|
-
};
|
|
155
|
-
reminders.set(id, reminder);
|
|
202
|
+
return scheduleFromCommand(trimmed, ctx);
|
|
203
|
+
}
|
|
156
204
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
id,
|
|
163
|
-
task: parsed.task,
|
|
164
|
-
delayMs: parsed.delayMs,
|
|
165
|
-
dueAt,
|
|
166
|
-
createdAt,
|
|
167
|
-
},
|
|
168
|
-
});
|
|
205
|
+
function handleDelayCancelCommand(args: string): string {
|
|
206
|
+
const target = args.trim();
|
|
207
|
+
if (!target || target === "all") return `✓ ${cancelAll()}개의 delay를 취소했어요.`;
|
|
208
|
+
return cancelTask(target) ? `✓ ${target} 예약을 취소했어요.` : `예약을 찾을 수 없어요: ${target}`;
|
|
209
|
+
}
|
|
169
210
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
211
|
+
function clearAllTimers(): void {
|
|
212
|
+
for (const task of tasks.values()) clearTimeout(task.timer);
|
|
213
|
+
tasks.clear();
|
|
214
|
+
if (statusInterval) clearInterval(statusInterval);
|
|
215
|
+
statusInterval = undefined;
|
|
216
|
+
}
|
|
174
217
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
218
|
+
export default function (pi: ExtensionAPI) {
|
|
219
|
+
api = pi;
|
|
220
|
+
pi.registerCommand("delay", {
|
|
221
|
+
description: "지정한 시간 후 프롬프트를 제출하고 턴 트리거: /delay 5m <프롬프트>",
|
|
222
|
+
getArgumentCompletions: (prefix) => {
|
|
223
|
+
const trimmed = prefix.trimStart();
|
|
224
|
+
if (trimmed.includes(" ")) return null;
|
|
225
|
+
return ["list", "5m", "30s", "1h"]
|
|
226
|
+
.filter((value) => value.startsWith(trimmed))
|
|
227
|
+
.map((value) => ({ value, label: value }));
|
|
228
|
+
},
|
|
229
|
+
handler: async (args, ctx) => {
|
|
178
230
|
latestCtx = ctx;
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
pi.sendMessage({
|
|
185
|
-
customType: CUSTOM_TYPE,
|
|
186
|
-
content: `Pending reminders\n\n${listReminderLines().join("\n")}`,
|
|
187
|
-
display: true,
|
|
188
|
-
});
|
|
231
|
+
const message = await handleDelayCommand(args ?? "", ctx);
|
|
232
|
+
ctx.ui.notify(
|
|
233
|
+
message,
|
|
234
|
+
message.startsWith("✓") || message.startsWith("예약된") || message.startsWith("Usage") ? "info" : "warning",
|
|
235
|
+
);
|
|
189
236
|
},
|
|
190
237
|
});
|
|
191
238
|
|
|
192
|
-
pi.registerCommand("
|
|
193
|
-
description: "
|
|
239
|
+
pi.registerCommand("delay-cancel", {
|
|
240
|
+
description: "delay 예약 취소. 사용법: /delay-cancel [id|all] (인자 생략 시 전체 취소)",
|
|
241
|
+
getArgumentCompletions: (prefix) => {
|
|
242
|
+
const trimmed = prefix.trimStart();
|
|
243
|
+
const items = [
|
|
244
|
+
{ value: "all", label: "all" },
|
|
245
|
+
...[...tasks.keys()].map((id) => ({ value: id, label: id })),
|
|
246
|
+
].filter((item) => item.value.startsWith(trimmed));
|
|
247
|
+
return items.length > 0 ? items : null;
|
|
248
|
+
},
|
|
194
249
|
handler: async (args, ctx) => {
|
|
195
250
|
latestCtx = ctx;
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
ctx.ui.notify("Usage: /reminder-cancel <id|all>", "info");
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
if (raw === "all") {
|
|
203
|
-
const count = reminders.size;
|
|
204
|
-
clearAllReminders();
|
|
205
|
-
ctx.ui.notify(`reminder ${count}개 취소됨`, "info");
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
const id = Number(raw);
|
|
210
|
-
if (!Number.isInteger(id)) {
|
|
211
|
-
ctx.ui.notify("id는 숫자여야 해. 예: /reminder-cancel 3", "warning");
|
|
212
|
-
return;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
const target = reminders.get(id);
|
|
216
|
-
if (!target) {
|
|
217
|
-
ctx.ui.notify(`reminder #${id} 없음`, "warning");
|
|
218
|
-
return;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
clearTimeout(target.timer);
|
|
222
|
-
reminders.delete(id);
|
|
223
|
-
ctx.ui.notify(`reminder #${id} 취소됨`, "info");
|
|
251
|
+
const message = handleDelayCancelCommand(args ?? "");
|
|
252
|
+
ctx.ui.notify(message, message.startsWith("✓") ? "info" : "warning");
|
|
224
253
|
},
|
|
225
254
|
});
|
|
226
255
|
|
|
227
|
-
pi.
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
256
|
+
pi.registerTool({
|
|
257
|
+
name: "delay",
|
|
258
|
+
label: "Delay",
|
|
259
|
+
description:
|
|
260
|
+
"Schedule a prompt to be submitted to the agent after a short delay, triggering a new turn when it fires. Use for one-shot reminders like 5m, 1h, or 2시간. The user can cancel with /delay-cancel <id> or /delay-cancel for all.",
|
|
261
|
+
promptSnippet: "Submit a prompt to the agent after a delay, e.g. delay=5m prompt='check status'.",
|
|
262
|
+
promptGuidelines: [
|
|
263
|
+
"Use delay only when the user explicitly asks to run a prompt later in the same interactive session.",
|
|
264
|
+
"When the delay fires the prompt is submitted as a user message and triggers a turn (followUp-queued if the agent is busy), not just inserted into the editor.",
|
|
265
|
+
"For recurring or persistent headless scheduled jobs, use cron instead of delay.",
|
|
266
|
+
"Tell the user the returned id so they can cancel it with `/delay-cancel <id>`; `/delay-cancel` without an id cancels all.",
|
|
267
|
+
],
|
|
268
|
+
parameters: DelayParamsSchema,
|
|
269
|
+
executionMode: "parallel",
|
|
270
|
+
async execute(_toolCallId, rawParams, _signal, _onUpdate, ctx) {
|
|
271
|
+
const params = rawParams as DelayToolParams;
|
|
272
|
+
const delayMs = parseDurationMs(params.delay);
|
|
273
|
+
if (delayMs === undefined) {
|
|
274
|
+
return {
|
|
275
|
+
content: [{ type: "text" as const, text: "Invalid delay. Use forms like 30s, 5m, 1h, 1h30m, 2시간." }],
|
|
276
|
+
details: {},
|
|
277
|
+
isError: true,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
if (!params.prompt.trim()) {
|
|
281
|
+
return {
|
|
282
|
+
content: [{ type: "text" as const, text: "prompt is required." }],
|
|
283
|
+
details: {},
|
|
284
|
+
isError: true,
|
|
285
|
+
};
|
|
235
286
|
}
|
|
236
|
-
return { action: "handled" as const };
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
const parsed = parseReminderRequest(text);
|
|
240
|
-
if (!parsed) return { action: "continue" as const };
|
|
241
|
-
|
|
242
|
-
scheduleReminder(parsed, ctx);
|
|
243
|
-
return { action: "handled" as const };
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
pi.on("agent_start", async (_event, ctx) => {
|
|
247
|
-
agentRunning = true;
|
|
248
|
-
latestCtx = ctx;
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
pi.on("agent_end", async (_event, ctx) => {
|
|
252
|
-
agentRunning = false;
|
|
253
|
-
latestCtx = ctx;
|
|
254
|
-
});
|
|
255
287
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
288
|
+
const task = scheduleDelay(ctx, delayMs, params.prompt.trim(), params.id);
|
|
289
|
+
return {
|
|
290
|
+
content: [
|
|
291
|
+
{
|
|
292
|
+
type: "text" as const,
|
|
293
|
+
text: `✓ ${task.id} scheduled: prompt will be submitted ${formatKoreanDuration(delayMs)} later (triggers a turn). Cancel with /delay-cancel ${task.id}`,
|
|
294
|
+
},
|
|
295
|
+
],
|
|
296
|
+
details: { id: task.id, dueAt: new Date(task.dueAt).toISOString(), prompt: task.prompt },
|
|
297
|
+
};
|
|
298
|
+
},
|
|
299
|
+
renderCall(args, theme) {
|
|
300
|
+
const params = args as Partial<DelayToolParams>;
|
|
301
|
+
return new Text(
|
|
302
|
+
`${theme.fg("toolTitle", theme.bold("delay "))}${theme.fg("accent", params.delay ?? "?")} ${theme.fg("dim", preview(params.prompt ?? ""))}`,
|
|
303
|
+
0,
|
|
304
|
+
0,
|
|
305
|
+
);
|
|
306
|
+
},
|
|
307
|
+
renderResult(result, _options, theme, context) {
|
|
308
|
+
const raw = result.content[0];
|
|
309
|
+
const text = raw?.type === "text" ? raw.text : "(no output)";
|
|
310
|
+
return new Text(context.isError ? theme.fg("error", text) : text, 0, 0);
|
|
311
|
+
},
|
|
266
312
|
});
|
|
267
313
|
|
|
268
314
|
pi.on("session_start", async (_event, ctx) => {
|
|
269
|
-
agentRunning = false;
|
|
270
315
|
latestCtx = ctx;
|
|
271
|
-
|
|
316
|
+
refreshStatus();
|
|
272
317
|
});
|
|
273
318
|
|
|
274
319
|
pi.on("session_shutdown", async () => {
|
|
275
|
-
|
|
276
|
-
clearAllReminders();
|
|
320
|
+
clearAllTimers();
|
|
277
321
|
});
|
|
278
322
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ryan_nookpi/pi-extension-delayed-action",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Pi extension for scheduling delayed follow-up
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "Pi extension for scheduling delayed follow-up prompts.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
],
|
|
19
19
|
"files": [
|
|
20
20
|
"index.ts",
|
|
21
|
+
"parse.ts",
|
|
21
22
|
"README.md"
|
|
22
23
|
],
|
|
23
24
|
"pi": {
|
|
@@ -26,11 +27,19 @@
|
|
|
26
27
|
]
|
|
27
28
|
},
|
|
28
29
|
"peerDependencies": {
|
|
29
|
-
"@earendil-works/pi-coding-agent": "*"
|
|
30
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
31
|
+
"@earendil-works/pi-tui": "*",
|
|
32
|
+
"@sinclair/typebox": "*"
|
|
30
33
|
},
|
|
31
34
|
"peerDependenciesMeta": {
|
|
32
35
|
"@earendil-works/pi-coding-agent": {
|
|
33
36
|
"optional": true
|
|
37
|
+
},
|
|
38
|
+
"@earendil-works/pi-tui": {
|
|
39
|
+
"optional": true
|
|
40
|
+
},
|
|
41
|
+
"@sinclair/typebox": {
|
|
42
|
+
"optional": true
|
|
34
43
|
}
|
|
35
44
|
},
|
|
36
45
|
"publishConfig": {
|
package/parse.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
export interface ParsedDelayArgs {
|
|
2
|
+
durationText: string;
|
|
3
|
+
delayMs: number;
|
|
4
|
+
prompt: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const MAX_DELAY_MS = 2_147_483_647; // setTimeout's practical upper bound (~24.8 days)
|
|
8
|
+
|
|
9
|
+
const UNIT_TO_MS: Record<string, number> = {
|
|
10
|
+
ms: 1,
|
|
11
|
+
millisecond: 1,
|
|
12
|
+
milliseconds: 1,
|
|
13
|
+
msec: 1,
|
|
14
|
+
msecs: 1,
|
|
15
|
+
second: 1000,
|
|
16
|
+
seconds: 1000,
|
|
17
|
+
sec: 1000,
|
|
18
|
+
secs: 1000,
|
|
19
|
+
s: 1000,
|
|
20
|
+
초: 1000,
|
|
21
|
+
minute: 60_000,
|
|
22
|
+
minutes: 60_000,
|
|
23
|
+
min: 60_000,
|
|
24
|
+
mins: 60_000,
|
|
25
|
+
m: 60_000,
|
|
26
|
+
분: 60_000,
|
|
27
|
+
hour: 3_600_000,
|
|
28
|
+
hours: 3_600_000,
|
|
29
|
+
hr: 3_600_000,
|
|
30
|
+
hrs: 3_600_000,
|
|
31
|
+
h: 3_600_000,
|
|
32
|
+
시간: 3_600_000,
|
|
33
|
+
day: 86_400_000,
|
|
34
|
+
days: 86_400_000,
|
|
35
|
+
d: 86_400_000,
|
|
36
|
+
일: 86_400_000,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const PART_RE =
|
|
40
|
+
/(\d+(?:\.\d+)?)\s*(milliseconds?|msecs?|ms|seconds?|secs?|sec|s|minutes?|mins?|min|m|hours?|hrs?|hr|h|days?|d|초|분|시간|일)/gi;
|
|
41
|
+
|
|
42
|
+
export function parseDurationMs(input: string): number | undefined {
|
|
43
|
+
const text = input.trim();
|
|
44
|
+
if (!text) return undefined;
|
|
45
|
+
|
|
46
|
+
let cursor = 0;
|
|
47
|
+
let total = 0;
|
|
48
|
+
let matched = false;
|
|
49
|
+
|
|
50
|
+
for (const match of text.matchAll(PART_RE)) {
|
|
51
|
+
const index = match.index ?? 0;
|
|
52
|
+
const between = text.slice(cursor, index);
|
|
53
|
+
if (between.trim().length > 0) return undefined;
|
|
54
|
+
|
|
55
|
+
const amount = Number(match[1]);
|
|
56
|
+
const unit = match[2].toLowerCase();
|
|
57
|
+
const unitMs = UNIT_TO_MS[unit];
|
|
58
|
+
if (!Number.isFinite(amount) || amount <= 0 || !unitMs) return undefined;
|
|
59
|
+
|
|
60
|
+
total += amount * unitMs;
|
|
61
|
+
cursor = index + match[0].length;
|
|
62
|
+
matched = true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!matched || text.slice(cursor).trim().length > 0) return undefined;
|
|
66
|
+
if (!Number.isFinite(total) || total <= 0 || total > MAX_DELAY_MS) return undefined;
|
|
67
|
+
return Math.round(total);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function isDurationToken(input: string): boolean {
|
|
71
|
+
return parseDurationMs(input) !== undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function parseDelayArgs(args: string): ParsedDelayArgs | { error: string } {
|
|
75
|
+
const trimmed = args.trim();
|
|
76
|
+
if (!trimmed) return { error: "Usage: /delay <duration> <prompt>" };
|
|
77
|
+
|
|
78
|
+
const tokens = trimmed.split(/\s+/);
|
|
79
|
+
const durationTokens: string[] = [];
|
|
80
|
+
let cursor = 0;
|
|
81
|
+
for (const token of tokens) {
|
|
82
|
+
if (!isDurationToken(token)) break;
|
|
83
|
+
durationTokens.push(token);
|
|
84
|
+
cursor += token.length;
|
|
85
|
+
while (trimmed[cursor] === " " || trimmed[cursor] === "\t" || trimmed[cursor] === "\n") cursor++;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (durationTokens.length === 0) {
|
|
89
|
+
return { error: "첫 인자는 지연 시간이어야 해요. 예: /delay 5m 나중에 확인" };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const durationText = durationTokens.join(" ");
|
|
93
|
+
const delayMs = parseDurationMs(durationText);
|
|
94
|
+
if (delayMs === undefined) {
|
|
95
|
+
return { error: "지연 시간을 해석할 수 없어요. 예: 30s, 5m, 1h, 1h30m, 2시간" };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const prompt = trimmed.slice(cursor).trim();
|
|
99
|
+
if (!prompt) return { error: "지연 후 입력할 프롬프트를 함께 적어주세요. 예: /delay 5m 진행 상황 확인해줘" };
|
|
100
|
+
|
|
101
|
+
return { durationText, delayMs, prompt };
|
|
102
|
+
}
|