@ryan_nookpi/pi-extension-delayed-action 0.2.1 → 0.3.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.
Files changed (4) hide show
  1. package/README.md +31 -11
  2. package/index.ts +258 -214
  3. package/package.json +12 -3
  4. 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
- 자연어로 "10분 있다가 배포 로그 확인해줘"처럼 말하면, 시간이 되면 pi가 다시 작업을 이어서 요청합니다.
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
- - "10분 있다가 배포 로그 확인해줘"
22
- - "1시간 후에 에러율 다시 체크해줘"
23
- - "좀 있다가 PR 코멘트 다시 확인해줘"
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
- /reminders
29
- /reminder-cancel <id>
30
- /reminder-cancel all
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 CUSTOM_TYPE = "delayed-action";
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
- type ParsedReminder = {
8
- task: string;
9
- delayMs: number;
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 EXPLICIT_DELAY_RE = /^(\d+)\s*(초|분|시간)\s*(?:있다가|후(?:에)?|뒤(?:에)?)\s*[,,:]?\s*(.+)$/i;
24
- const SOON_DELAY_RE = /^(?:좀|조금|잠깐|잠시)\s*(?:있다가|후(?:에)?|뒤(?:에)?)\s*[,,:]?\s*(.+)$/i;
25
- const DELAY_ONLY_RE = /^(?:\d+\s*(?:초|분|시간)|(?:좀|조금|잠깐|잠시))\s*(?:있다가|후(?:에)?|뒤(?:에)?)\s*$/i;
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
- function toDelayMs(amount: number, unit: "초" | "분" | "시간"): number {
28
- if (unit === "초") return amount * 1000;
29
- if (unit === "시간") return amount * 60 * 60 * 1000;
30
- return amount * 60 * 1000;
23
+ interface DelayToolParams {
24
+ delay: string;
25
+ prompt: string;
26
+ id?: string;
31
27
  }
32
28
 
33
- function formatDuration(ms: number): string {
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 formatClock(ts: number): string {
44
- return new Date(ts).toLocaleTimeString("ko-KR", {
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 parseReminderRequest(text: string): ParsedReminder | null {
53
- const trimmed = text.trim();
54
- if (!trimmed) return null;
55
-
56
- const explicit = trimmed.match(EXPLICIT_DELAY_RE);
57
- if (explicit) {
58
- const amount = Number(explicit[1]);
59
- const unit = explicit[2] as "초" | "분" | "시간";
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
- return {
67
- task,
68
- delayMs,
69
- delayLabel: `${amount}${unit}`,
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
- const soon = trimmed.match(SOON_DELAY_RE);
74
- if (soon) {
75
- const task = soon[1]?.trim() ?? "";
76
- if (!task) return null;
77
- return {
78
- task,
79
- delayMs: DEFAULT_SOON_DELAY_MS,
80
- delayLabel: formatDuration(DEFAULT_SOON_DELAY_MS),
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
- return null;
99
+ function ensureStatusTicker(): void {
100
+ if (statusInterval) return;
101
+ statusInterval = setInterval(refreshStatus, 1000);
85
102
  }
86
103
 
87
- export default function (pi: ExtensionAPI) {
88
- const reminders = new Map<number, Reminder>();
89
- let nextReminderId = 1;
90
- let agentRunning = false;
91
- let latestCtx: ExtensionContext | undefined;
104
+ function stopStatusTickerIfIdle(): void {
105
+ if (tasks.size > 0 || !statusInterval) return;
106
+ clearInterval(statusInterval);
107
+ statusInterval = undefined;
108
+ }
92
109
 
93
- const clearAllReminders = () => {
94
- for (const reminder of reminders.values()) {
95
- clearTimeout(reminder.timer);
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
- reminders.clear();
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
- const listReminderLines = (): string[] => {
101
- const now = Date.now();
102
- return Array.from(reminders.values())
103
- .sort((a, b) => a.dueAt - b.dueAt)
104
- .map((r) => {
105
- const remainMs = Math.max(0, r.dueAt - now);
106
- return `#${r.id} · ${formatDuration(remainMs)} 후 · ${r.task}`;
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
- const fireReminder = (id: number) => {
111
- const reminder = reminders.get(id);
112
- if (!reminder) return;
113
-
114
- reminders.delete(id);
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
- pi.sendMessage({
117
- customType: CUSTOM_TYPE,
118
- content: `[reminder#${reminder.id}] 시간 도달 (${formatClock(Date.now())})\nTask: ${reminder.task}`,
119
- display: true,
120
- details: {
121
- id: reminder.id,
122
- task: reminder.task,
123
- dueAt: reminder.dueAt,
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
- const prompt = `예약한 시간이 되었어. 지금 아래 작업을 수행해줘.\n\n${reminder.task}`;
129
- if (agentRunning) {
130
- pi.sendUserMessage(prompt, { deliverAs: "followUp" });
131
- } else {
132
- pi.sendUserMessage(prompt);
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
- if (latestCtx?.hasUI) {
136
- latestCtx.ui.notify(`⏰ reminder #${reminder.id} 실행됨`, "info");
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
- const scheduleReminder = (parsed: ParsedReminder, ctx: ExtensionContext) => {
141
- const id = nextReminderId++;
142
- const createdAt = Date.now();
143
- const dueAt = createdAt + parsed.delayMs;
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
- const timer = setTimeout(() => fireReminder(id), parsed.delayMs);
146
- const reminder: Reminder = {
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
- pi.sendMessage({
158
- customType: CUSTOM_TYPE,
159
- content: `[reminder#${id}] 예약됨: ${parsed.delayLabel} 후\nTask: ${parsed.task}\nETA: ${formatClock(dueAt)}`,
160
- display: true,
161
- details: {
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
- if (ctx.hasUI) {
171
- ctx.ui.notify(`⏰ reminder #${id} 설정됨 (${parsed.delayLabel})`, "info");
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
- pi.registerCommand("reminders", {
176
- description: "List pending reminders",
177
- handler: async (_args, ctx) => {
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
- if (reminders.size === 0) {
180
- ctx.ui.notify("현재 예약된 reminder가 없어.", "info");
181
- return;
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("reminder-cancel", {
193
- description: "Cancel reminder by id or all (usage: /reminder-cancel <id|all>)",
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 raw = (args ?? "").trim().toLowerCase();
197
- if (!raw) {
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.on("input", async (event, ctx) => {
228
- latestCtx = ctx;
229
- if (event.source === "extension") return { action: "continue" as const };
230
-
231
- const text = event.text ?? "";
232
- if (DELAY_ONLY_RE.test(text.trim())) {
233
- if (ctx.hasUI) {
234
- ctx.ui.notify('예약할 작업도 같이 써줘. 예: "10분 있다가 배포 로그 확인해"', "warning");
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
- // Filter out delayed-action log messages before LLM sees them.
257
- // CustomMessageEntry (created by sendMessage) has role="custom" and participates
258
- // in LLM context by default. We strip them here so they remain visible in the TUI
259
- // (display:true is handled by the UI layer independently) but never reach the model.
260
- pi.on("context", async (event, _ctx) => {
261
- const filtered = event.messages.filter(
262
- (m) => !(m.role === "custom" && (m as { customType?: string }).customType === CUSTOM_TYPE),
263
- );
264
- if (filtered.length === event.messages.length) return;
265
- return { messages: filtered };
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
- clearAllReminders();
316
+ refreshStatus();
272
317
  });
273
318
 
274
319
  pi.on("session_shutdown", async () => {
275
- agentRunning = false;
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.2.1",
4
- "description": "Pi extension for scheduling delayed follow-up actions.",
3
+ "version": "0.3.0",
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
+ }