diff --git a/copilot-sdk b/copilot-sdk index ba3571b..fb767b5 160000 --- a/copilot-sdk +++ b/copilot-sdk @@ -1 +1 @@ -Subproject commit ba3571bb19f7e98b8e6fea4dd644fad243c9354e +Subproject commit fb767b5090fd07b20108f501fb7039dd02c0890e diff --git a/docs/jp/session-lifecycle.md b/docs/jp/session-lifecycle.md new file mode 100644 index 0000000..db3b071 --- /dev/null +++ b/docs/jp/session-lifecycle.md @@ -0,0 +1,154 @@ +# セッションライフサイクルイベント + +セッションライフサイクルイベントは、セッションの作成、削除、更新、およびフォアグラウンド/バックグラウンドの状態変更(TUI+サーバーモード時)を通知するイベントです。 + +## イベントタイプ + +`SessionLifecycleEventType` enumで定義されている5つのイベントタイプがあります: + +| イベント | 説明 | +|---------|------| +| `session.created` | 新しいセッションが作成された | +| `session.deleted` | セッションが削除された | +| `session.updated` | セッションが更新された | +| `session.foreground` | セッションがフォアグラウンドに移動した | +| `session.background` | セッションがバックグラウンドに移動した | + +```php +use Revolution\Copilot\Enums\SessionLifecycleEventType; + +// イベントタイプの値を取得 +echo SessionLifecycleEventType::SESSION_CREATED->value; // 'session.created' +``` + +## イベントの購読 + +`onLifecycle()` メソッドを使用してセッションライフサイクルイベントを購読できます: + +```php +use Revolution\Copilot\Facades\Copilot; +use Revolution\Copilot\Types\SessionLifecycleEvent; + +// Clientを取得してライフサイクルイベントを購読 +$client = Copilot::client(); + +// 全てのライフサイクルイベントを購読 +$unsubscribe = $client->onLifecycle(function (SessionLifecycleEvent $event) { + match ($event->type) { + SessionLifecycleEventType::SESSION_CREATED => info("セッション作成: {$event->sessionId}"), + SessionLifecycleEventType::SESSION_DELETED => info("セッション削除: {$event->sessionId}"), + SessionLifecycleEventType::SESSION_FOREGROUND => info("フォアグラウンド: {$event->sessionId}"), + SessionLifecycleEventType::SESSION_BACKGROUND => info("バックグラウンド: {$event->sessionId}"), + default => null, + }; +}); + +// 購読を解除 +$unsubscribe(); +``` + +## SessionLifecycleEvent + +`SessionLifecycleEvent` はライフサイクルイベントの詳細を含むreadonly classです: + +```php +use Revolution\Copilot\Types\SessionLifecycleEvent; +use Revolution\Copilot\Types\SessionLifecycleEventMetadata; + +readonly class SessionLifecycleEvent implements Arrayable +{ + public function __construct( + public SessionLifecycleEventType $type, // イベントタイプ + public string $sessionId, // セッションID + public ?SessionLifecycleEventMetadata $metadata = null, // メタデータ(削除時は含まれない) + ) {} +} +``` + +### SessionLifecycleEventMetadata + +セッションのメタデータを含むクラスです(削除イベントには含まれません): + +```php +readonly class SessionLifecycleEventMetadata implements Arrayable +{ + public function __construct( + public string $startTime, // セッション開始時刻 + public string $modifiedTime, // 最終更新時刻 + public ?string $summary = null, // セッションの要約 + ) {} +} +``` + +## フォアグラウンドセッション管理 + +TUI+サーバーモード(`--ui-server`)で動作しているサーバーに接続している場合、フォアグラウンドセッションを管理できます。 + +### 現在のフォアグラウンドセッションを取得 + +```php +use Revolution\Copilot\Facades\Copilot; + +$client = Copilot::client(); + +// TUIに表示されている現在のセッションIDを取得 +$sessionId = $client->getForegroundSessionId(); + +if ($sessionId !== null) { + echo "現在のフォアグラウンドセッション: {$sessionId}"; +} +``` + +### フォアグラウンドセッションを設定 + +```php +use Revolution\Copilot\Facades\Copilot; + +$client = Copilot::client(); + +// TUIに特定のセッションを表示 +$client->setForegroundSessionId('session-123'); +``` + +## ForegroundSessionInfo + +フォアグラウンドセッションの情報を含むreadonly classです: + +```php +use Revolution\Copilot\Types\ForegroundSessionInfo; + +readonly class ForegroundSessionInfo implements Arrayable +{ + public function __construct( + public ?string $sessionId = null, // フォアグラウンドセッションID + public ?string $workspacePath = null, // ワークスペースパス + ) {} +} +``` + +## TCPモードでの使用 + +TUI+サーバーモードでCopilot CLIを起動し、SDKから接続することでライフサイクルイベントを活用できます: + +```bash +# TUI+サーバーモードでCopilot CLIを起動 +copilot --ui-server --port 8080 +``` + +```php +use Revolution\Copilot\Facades\Copilot; + +// TCPモードでサーバーに接続 +$client = Copilot::useTcp('tcp://127.0.0.1:8080')->client(); + +// ライフサイクルイベントを購読 +$client->onLifecycle(function (SessionLifecycleEvent $event) { + // イベント処理 +}); +``` + +## 注意事項 + +- `session.foreground` と `session.background` イベントは、TUI+サーバーモードでのみ発生します +- セッション削除時(`session.deleted`)には `metadata` プロパティは `null` になります +- `onLifecycle()` は購読解除用のコールバック関数を返します diff --git a/src/Client.php b/src/Client.php index d76c29f..0b2ac5a 100644 --- a/src/Client.php +++ b/src/Client.php @@ -21,12 +21,14 @@ use Revolution\Copilot\JsonRpc\JsonRpcClient; use Revolution\Copilot\Process\ProcessManager; use Revolution\Copilot\Transport\TcpTransport; +use Revolution\Copilot\Types\ForegroundSessionInfo; use Revolution\Copilot\Types\GetAuthStatusResponse; use Revolution\Copilot\Types\GetStatusResponse; use Revolution\Copilot\Types\ModelInfo; use Revolution\Copilot\Types\ResumeSessionConfig; use Revolution\Copilot\Types\SessionConfig; use Revolution\Copilot\Types\SessionEvent; +use Revolution\Copilot\Types\SessionLifecycleEvent; use Revolution\Copilot\Types\SessionMetadata; use Revolution\Copilot\Types\ToolResultObject; use Revolution\Copilot\Types\UserInputRequest; @@ -63,6 +65,13 @@ class Client implements CopilotClient */ protected array $sessions = []; + /** + * Session lifecycle event handlers. + * + * @var array + */ + protected array $lifecycleHandlers = []; + /** * Create a new CopilotClient. */ @@ -499,6 +508,65 @@ public function listSessions(): array ); } + /** + * Gets the foreground session ID in TUI+server mode. + * + * This returns the ID of the session currently displayed in the TUI. + * Only available when connecting to a server running in TUI+server mode (--ui-server). + * + * @throws JsonRpcException + */ + public function getForegroundSessionId(): ?string + { + $this->ensureConnected(); + + $response = $this->rpcClient->request('session.getForeground', []); + + return ForegroundSessionInfo::fromArray($response)->sessionId; + } + + /** + * Sets the foreground session in TUI+server mode. + * + * This requests the TUI to switch to displaying the specified session. + * Only available when connecting to a server running in TUI+server mode (--ui-server). + * + * @throws RuntimeException|JsonRpcException + */ + public function setForegroundSessionId(string $sessionId): void + { + $this->ensureConnected(); + + $response = $this->rpcClient->request('session.setForeground', [ + 'sessionId' => $sessionId, + ]); + + if (! ($response['success'] ?? false)) { + throw new RuntimeException($response['error'] ?? 'Failed to set foreground session'); + } + } + + /** + * Subscribes to session lifecycle events. + * + * Lifecycle events are emitted when sessions are created, deleted, updated, + * or change foreground/background state (in TUI+server mode). + * + * @param callable(SessionLifecycleEvent): void $handler + * @return callable(): void A function that, when called, unsubscribes the handler + */ + public function onLifecycle(callable $handler): callable + { + $this->lifecycleHandlers[] = $handler; + + return function () use ($handler) { + $this->lifecycleHandlers = array_filter( + $this->lifecycleHandlers, + fn ($h) => $h !== $handler, + ); + }; + } + /** * Ensure the client is connected. * @@ -551,6 +619,40 @@ protected function handleNotification(string $method, array $params): void $this->sessions[$sessionId]->dispatchEvent($event); } } + + if ($method === 'session.lifecycle') { + $this->handleLifecycleNotification($params); + } + } + + /** + * Handle session lifecycle notifications. + */ + protected function handleLifecycleNotification(array $params): void + { + // Validate required fields + if (! isset($params['type']) || ! is_string($params['type'])) { + return; + } + + if (! isset($params['sessionId']) || ! is_string($params['sessionId'])) { + return; + } + + try { + $event = SessionLifecycleEvent::fromArray($params); + + // Dispatch to all registered handlers + foreach ($this->lifecycleHandlers as $handler) { + try { + $handler($event); + } catch (Throwable) { + // Ignore handler errors + } + } + } catch (Throwable) { + // Ignore parsing errors for invalid event types + } } /** diff --git a/src/Contracts/CopilotClient.php b/src/Contracts/CopilotClient.php index 8c6acc7..7a7a8ed 100644 --- a/src/Contracts/CopilotClient.php +++ b/src/Contracts/CopilotClient.php @@ -10,6 +10,7 @@ use Revolution\Copilot\Types\ModelInfo; use Revolution\Copilot\Types\ResumeSessionConfig; use Revolution\Copilot\Types\SessionConfig; +use Revolution\Copilot\Types\SessionLifecycleEvent; use Throwable; /** @@ -68,6 +69,28 @@ public function getAuthStatus(): GetAuthStatusResponse; */ public function listModels(): array; + /** + * Gets the foreground session ID in TUI+server mode. + * + * @throws JsonRpcException + */ + public function getForegroundSessionId(): ?string; + + /** + * Sets the foreground session in TUI+server mode. + * + * @throws JsonRpcException + */ + public function setForegroundSessionId(string $sessionId): void; + + /** + * Subscribes to session lifecycle events. + * + * @param callable(SessionLifecycleEvent): void $handler + * @return callable(): void A function that, when called, unsubscribes the handler + */ + public function onLifecycle(callable $handler): callable; + /** * Stop the CLI server and close all sessions. * diff --git a/src/Enums/SessionEventType.php b/src/Enums/SessionEventType.php index 68ae7ad..068ac4d 100644 --- a/src/Enums/SessionEventType.php +++ b/src/Enums/SessionEventType.php @@ -18,6 +18,8 @@ enum SessionEventType: string case SESSION_MODEL_CHANGE = 'session.model_change'; case SESSION_HANDOFF = 'session.handoff'; case SESSION_TRUNCATION = 'session.truncation'; + case SESSION_SNAPSHOT_REWIND = 'session.snapshot_rewind'; + case SESSION_SHUTDOWN = 'session.shutdown'; case SESSION_USAGE_INFO = 'session.usage_info'; case SESSION_COMPACTION_START = 'session.compaction_start'; case SESSION_COMPACTION_COMPLETE = 'session.compaction_complete'; @@ -46,6 +48,9 @@ enum SessionEventType: string case TOOL_EXECUTION_PROGRESS = 'tool.execution_progress'; case TOOL_EXECUTION_COMPLETE = 'tool.execution_complete'; + // Skill events + case SKILL_INVOKED = 'skill.invoked'; + // Subagent events case SUBAGENT_STARTED = 'subagent.started'; case SUBAGENT_COMPLETED = 'subagent.completed'; diff --git a/src/Enums/SessionLifecycleEventType.php b/src/Enums/SessionLifecycleEventType.php new file mode 100644 index 0000000..3179a31 --- /dev/null +++ b/src/Enums/SessionLifecycleEventType.php @@ -0,0 +1,20 @@ +githubToken; } - $args = ['--server', '--stdio', '--log-level', $this->logLevel]; + $args = ['--headless', '--stdio', '--log-level', $this->logLevel]; // Add auth-related flags if (filled($this->githubToken)) { diff --git a/src/Types/ForegroundSessionInfo.php b/src/Types/ForegroundSessionInfo.php new file mode 100644 index 0000000..ba16117 --- /dev/null +++ b/src/Types/ForegroundSessionInfo.php @@ -0,0 +1,49 @@ + + */ +readonly class ForegroundSessionInfo implements Arrayable +{ + public function __construct( + /** + * ID of the foreground session, or null if none. + */ + public ?string $sessionId = null, + + /** + * Workspace path of the foreground session. + */ + public ?string $workspacePath = null, + ) {} + + /** + * Create from array. + */ + public static function fromArray(array $data): self + { + return new self( + sessionId: $data['sessionId'] ?? null, + workspacePath: $data['workspacePath'] ?? null, + ); + } + + /** + * @return array + */ + public function toArray(): array + { + return array_filter([ + 'sessionId' => $this->sessionId, + 'workspacePath' => $this->workspacePath, + ], fn ($value) => $value !== null); + } +} diff --git a/src/Types/SessionLifecycleEvent.php b/src/Types/SessionLifecycleEvent.php new file mode 100644 index 0000000..7b01db3 --- /dev/null +++ b/src/Types/SessionLifecycleEvent.php @@ -0,0 +1,59 @@ + + */ +readonly class SessionLifecycleEvent implements Arrayable +{ + public function __construct( + /** + * Type of lifecycle event. + */ + public SessionLifecycleEventType $type, + + /** + * ID of the session this event relates to. + */ + public string $sessionId, + + /** + * Session metadata (not included for deleted sessions). + */ + public ?SessionLifecycleEventMetadata $metadata = null, + ) {} + + /** + * Create from array. + */ + public static function fromArray(array $data): self + { + return new self( + type: SessionLifecycleEventType::from($data['type']), + sessionId: $data['sessionId'], + metadata: isset($data['metadata']) ? SessionLifecycleEventMetadata::fromArray($data['metadata']) : null, + ); + } + + /** + * @return array + */ + public function toArray(): array + { + return array_filter([ + 'type' => $this->type->value, + 'sessionId' => $this->sessionId, + 'metadata' => $this->metadata?->toArray(), + ], fn ($value) => $value !== null); + } +} diff --git a/src/Types/SessionLifecycleEventMetadata.php b/src/Types/SessionLifecycleEventMetadata.php new file mode 100644 index 0000000..0b89cd3 --- /dev/null +++ b/src/Types/SessionLifecycleEventMetadata.php @@ -0,0 +1,45 @@ + + */ +readonly class SessionLifecycleEventMetadata implements Arrayable +{ + public function __construct( + public string $startTime, + public string $modifiedTime, + public ?string $summary = null, + ) {} + + /** + * Create from array. + */ + public static function fromArray(array $data): self + { + return new self( + startTime: $data['startTime'], + modifiedTime: $data['modifiedTime'], + summary: $data['summary'] ?? null, + ); + } + + /** + * @return array + */ + public function toArray(): array + { + return array_filter([ + 'startTime' => $this->startTime, + 'modifiedTime' => $this->modifiedTime, + 'summary' => $this->summary, + ], fn ($value) => $value !== null); + } +} diff --git a/tests/Unit/Enums/SessionLifecycleEventTypeTest.php b/tests/Unit/Enums/SessionLifecycleEventTypeTest.php new file mode 100644 index 0000000..8a2018e --- /dev/null +++ b/tests/Unit/Enums/SessionLifecycleEventTypeTest.php @@ -0,0 +1,40 @@ +value)->toBe('session.created'); + }); + + it('has session.deleted case', function () { + expect(SessionLifecycleEventType::SESSION_DELETED->value)->toBe('session.deleted'); + }); + + it('has session.updated case', function () { + expect(SessionLifecycleEventType::SESSION_UPDATED->value)->toBe('session.updated'); + }); + + it('has session.foreground case', function () { + expect(SessionLifecycleEventType::SESSION_FOREGROUND->value)->toBe('session.foreground'); + }); + + it('has session.background case', function () { + expect(SessionLifecycleEventType::SESSION_BACKGROUND->value)->toBe('session.background'); + }); + + it('can be created from value', function () { + expect(SessionLifecycleEventType::from('session.created')) + ->toBe(SessionLifecycleEventType::SESSION_CREATED); + }); + + it('can be created from all valid values', function () { + $values = ['session.created', 'session.deleted', 'session.updated', 'session.foreground', 'session.background']; + + foreach ($values as $value) { + expect(SessionLifecycleEventType::from($value))->toBeInstanceOf(SessionLifecycleEventType::class); + } + }); +}); diff --git a/tests/Unit/Types/ForegroundSessionInfoTest.php b/tests/Unit/Types/ForegroundSessionInfoTest.php new file mode 100644 index 0000000..19b3f79 --- /dev/null +++ b/tests/Unit/Types/ForegroundSessionInfoTest.php @@ -0,0 +1,50 @@ + 'session-123', + 'workspacePath' => '/path/to/workspace', + ]); + + expect($info->sessionId)->toBe('session-123') + ->and($info->workspacePath)->toBe('/path/to/workspace'); + }); + + it('can be created from array with no fields', function () { + $info = ForegroundSessionInfo::fromArray([]); + + expect($info->sessionId)->toBeNull() + ->and($info->workspacePath)->toBeNull(); + }); + + it('can convert to array with all fields', function () { + $info = new ForegroundSessionInfo( + sessionId: 'session-456', + workspacePath: '/another/path', + ); + + $array = $info->toArray(); + + expect($array['sessionId'])->toBe('session-456') + ->and($array['workspacePath'])->toBe('/another/path'); + }); + + it('filters null values in toArray', function () { + $info = new ForegroundSessionInfo; + + $array = $info->toArray(); + + expect($array)->toBe([]); + }); + + it('implements Arrayable interface', function () { + $info = new ForegroundSessionInfo; + + expect($info)->toBeInstanceOf(\Illuminate\Contracts\Support\Arrayable::class); + }); +}); diff --git a/tests/Unit/Types/SessionLifecycleEventTest.php b/tests/Unit/Types/SessionLifecycleEventTest.php new file mode 100644 index 0000000..65b83b1 --- /dev/null +++ b/tests/Unit/Types/SessionLifecycleEventTest.php @@ -0,0 +1,152 @@ + '2026-01-24T10:00:00Z', + 'modifiedTime' => '2026-01-24T10:30:00Z', + 'summary' => 'Test session summary', + ]); + + expect($metadata->startTime)->toBe('2026-01-24T10:00:00Z') + ->and($metadata->modifiedTime)->toBe('2026-01-24T10:30:00Z') + ->and($metadata->summary)->toBe('Test session summary'); + }); + + it('can be created from array without summary', function () { + $metadata = SessionLifecycleEventMetadata::fromArray([ + 'startTime' => '2026-01-24T10:00:00Z', + 'modifiedTime' => '2026-01-24T10:30:00Z', + ]); + + expect($metadata->startTime)->toBe('2026-01-24T10:00:00Z') + ->and($metadata->modifiedTime)->toBe('2026-01-24T10:30:00Z') + ->and($metadata->summary)->toBeNull(); + }); + + it('can convert to array', function () { + $metadata = new SessionLifecycleEventMetadata( + startTime: '2026-01-24T10:00:00Z', + modifiedTime: '2026-01-24T10:30:00Z', + summary: 'Test summary', + ); + + $array = $metadata->toArray(); + + expect($array)->toBe([ + 'startTime' => '2026-01-24T10:00:00Z', + 'modifiedTime' => '2026-01-24T10:30:00Z', + 'summary' => 'Test summary', + ]); + }); + + it('filters null values in toArray', function () { + $metadata = new SessionLifecycleEventMetadata( + startTime: '2026-01-24T10:00:00Z', + modifiedTime: '2026-01-24T10:30:00Z', + ); + + $array = $metadata->toArray(); + + expect($array)->toBe([ + 'startTime' => '2026-01-24T10:00:00Z', + 'modifiedTime' => '2026-01-24T10:30:00Z', + ]); + }); +}); + +describe('SessionLifecycleEvent', function () { + it('can be created from array with all fields', function () { + $event = SessionLifecycleEvent::fromArray([ + 'type' => 'session.created', + 'sessionId' => 'session-123', + 'metadata' => [ + 'startTime' => '2026-01-24T10:00:00Z', + 'modifiedTime' => '2026-01-24T10:30:00Z', + 'summary' => 'Test summary', + ], + ]); + + expect($event->type)->toBe(SessionLifecycleEventType::SESSION_CREATED) + ->and($event->sessionId)->toBe('session-123') + ->and($event->metadata)->toBeInstanceOf(SessionLifecycleEventMetadata::class) + ->and($event->metadata->summary)->toBe('Test summary'); + }); + + it('can be created from array without metadata', function () { + $event = SessionLifecycleEvent::fromArray([ + 'type' => 'session.deleted', + 'sessionId' => 'session-456', + ]); + + expect($event->type)->toBe(SessionLifecycleEventType::SESSION_DELETED) + ->and($event->sessionId)->toBe('session-456') + ->and($event->metadata)->toBeNull(); + }); + + it('can be created for foreground event', function () { + $event = SessionLifecycleEvent::fromArray([ + 'type' => 'session.foreground', + 'sessionId' => 'session-789', + ]); + + expect($event->type)->toBe(SessionLifecycleEventType::SESSION_FOREGROUND) + ->and($event->sessionId)->toBe('session-789'); + }); + + it('can be created for background event', function () { + $event = SessionLifecycleEvent::fromArray([ + 'type' => 'session.background', + 'sessionId' => 'session-abc', + ]); + + expect($event->type)->toBe(SessionLifecycleEventType::SESSION_BACKGROUND) + ->and($event->sessionId)->toBe('session-abc'); + }); + + it('can convert to array with all fields', function () { + $event = new SessionLifecycleEvent( + type: SessionLifecycleEventType::SESSION_UPDATED, + sessionId: 'session-xyz', + metadata: new SessionLifecycleEventMetadata( + startTime: '2026-01-24T10:00:00Z', + modifiedTime: '2026-01-24T11:00:00Z', + ), + ); + + $array = $event->toArray(); + + expect($array['type'])->toBe('session.updated') + ->and($array['sessionId'])->toBe('session-xyz') + ->and($array['metadata']['startTime'])->toBe('2026-01-24T10:00:00Z'); + }); + + it('filters null values in toArray', function () { + $event = new SessionLifecycleEvent( + type: SessionLifecycleEventType::SESSION_DELETED, + sessionId: 'session-del', + ); + + $array = $event->toArray(); + + expect($array)->toBe([ + 'type' => 'session.deleted', + 'sessionId' => 'session-del', + ]); + }); + + it('implements Arrayable interface', function () { + $event = new SessionLifecycleEvent( + type: SessionLifecycleEventType::SESSION_CREATED, + sessionId: 'session-new', + ); + + expect($event)->toBeInstanceOf(\Illuminate\Contracts\Support\Arrayable::class); + }); +});